Replace account_bindings with per-app default account; build analytics charts
Notification parsing: - Drop the account_bindings table/DAO/repo/entity/controller/screen; account resolution now goes senderToAccount rule -> source_apps.defaultAccountId (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration. - Add defaultAccountId to source_apps; per-app settings consolidated into source_app_detail_screen (/settings/parsing/apps/:pkg). - Inbox auto-learns an app default on first Confirm/CreateRule; account picker on the card instead of a disabled button; parse_error_labels extracted. Analytics: - Replace placeholder screen with fl_chart cards (chart_card, chart_theme, month_stepper, month_math domain helper); slim down habit_analysis_screen. Android: add launcher icon (adaptive foreground + colors.xml) and app_name. Tests: migration_v2, analytics (screen/month_math), AI retry, inbox visibility; update resolver/gate/inbox suites for the new resolution path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,8 @@
|
|||||||
"Bash(command -v convert)",
|
"Bash(command -v convert)",
|
||||||
"Read(//c/Users/user/AppData/Local/Pub/Cache/**)",
|
"Read(//c/Users/user/AppData/Local/Pub/Cache/**)",
|
||||||
"Bash(flutter pub *)",
|
"Bash(flutter pub *)",
|
||||||
"Bash(git add *)"
|
"Bash(git add *)",
|
||||||
|
"WebFetch(domain:pub.dev)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=11
|
database/app_database.dart # @DriftDatabase, schemaVersion=2 (+onUpgrade v1→v2)
|
||||||
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)
|
||||||
@@ -115,13 +115,24 @@ checked by the gate; +`packageName` — rules are per-app: pipeline loads only r
|
|||||||
message's source app via `getEnabledForApp` (NULL = legacy global rows still match anywhere);
|
message's source app via `getEnabledForApp` (NULL = legacy global rows still match anywhere);
|
||||||
every create path must pass `packageName`),
|
every create path must pass `packageName`),
|
||||||
`rule_candidates` (NOT app-scoped — key is `userId+kind+rawValue`),
|
`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",
|
`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),
|
e.g. Ozon: suppresses AI category prefill and the rule suggestion in Inbox;
|
||||||
|
+`defaultAccountId` — the app's default account, FK not enforced → tolerate dangling ids),
|
||||||
`transfer_pairing_blocklist`. Their enums live
|
`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`).
|
||||||
|
|
||||||
|
**Account resolution** (`data/parser/account_resolver.dart`, pure sync fn `resolveAccount`):
|
||||||
|
`senderToAccount` rule (pattern in body → account, trusted) → `source_apps.defaultAccountId`
|
||||||
|
(trusted) → global default account (**NOT trusted** → Inbox with prefill) → none. The app
|
||||||
|
default auto-learns: first Confirm/CreateRule in Inbox for an app without a default stores the
|
||||||
|
chosen account (`InboxController._maybeSetAppDefault`, returns `true` → card shows a SnackBar;
|
||||||
|
`learnAppDefault: false` skips). The old `account_bindings` table (card/phone → account) was
|
||||||
|
dropped in the v1→v2 migration: per-app default (or single) binding became `defaultAccountId`,
|
||||||
|
card/phone bindings became `senderToAccount` contains-rules. Per-app settings live on one
|
||||||
|
screen: `source_app_detail_screen.dart` (`/settings/parsing/apps/:pkg` — enabled, selfMerchant,
|
||||||
|
default account picker, senderToAccount rules of that app).
|
||||||
|
|
||||||
**Auto-apply gate** (`data/parser/decision_gate.dart`): no numeric confidence threshold —
|
**Auto-apply gate** (`data/parser/decision_gate.dart`): no numeric confidence threshold —
|
||||||
a checklist of named `AutoApplyCheck`s (rule matched, amount literally found in body,
|
a checklist of named `AutoApplyCheck`s (rule matched, amount literally found in body,
|
||||||
currency known, draft type == rule `txType` (null = skip), account resolved+trusted,
|
currency known, draft type == rule `txType` (null = skip), account resolved+trusted,
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release signing: android/key.properties (gitignored) points to a permanent
|
||||||
|
// keystore outside the repo, so every release build has the same signature and
|
||||||
|
// installs update in place without wiping app data.
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val file = rootProject.file("key.properties")
|
||||||
|
if (file.exists()) file.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.sanders.budget.new_budget"
|
namespace = "com.sanders.budget.new_budget"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -25,11 +35,24 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
}
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
debug {
|
||||||
|
// Install debug builds under a separate applicationId so they don't
|
||||||
|
// overwrite an installed release build (and vice versa).
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- В release-манифест Flutter НЕ добавляет INTERNET автоматически (в отличие
|
||||||
|
от debug/profile) — без этой строки все сетевые вызовы падают. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="14%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#59C9FD</color>
|
||||||
|
</resources>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Workflow определения счёта при парсинге уведомлений
|
# Workflow определения счёта при парсинге уведомлений
|
||||||
|
|
||||||
|
> ⚠️ **Реализовано и затем упрощено (2026-07):** описанная здесь модель привязок
|
||||||
|
> `account_bindings` (карта/телефон → счёт, per-app default со звёздочкой) удалена в
|
||||||
|
> schemaVersion 2. Действующая модель: правило `senderToAccount` →
|
||||||
|
> `source_apps.defaultAccountId` → глобальный дефолт (не trusted). Документ — история.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Приложение парсит уведомления банков и создаёт черновики транзакций. Проблемы по текущему коду:
|
Приложение парсит уведомления банков и создаёт черновики транзакций. Проблемы по текущему коду:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Парсинг push-уведомлений банков → транзакции
|
# Парсинг push-уведомлений банков → транзакции
|
||||||
|
|
||||||
|
> ⚠️ **Устарело в части счетов (2026-07):** таблица `account_bindings` (карта/телефон → счёт)
|
||||||
|
> удалена в schemaVersion 2. Счёт разрешается лестницей: правило `senderToAccount` →
|
||||||
|
> `source_apps.defaultAccountId` (авто-обучается первым Confirm в Inbox) → глобальный
|
||||||
|
> дефолт (**не** trusted → Inbox). См. `data/parser/account_resolver.dart` и CLAUDE.md.
|
||||||
|
|
||||||
Спецификация фичи: автоматическое создание транзакций из системных push-уведомлений банковских приложений. Платформа — Android. Парсинг — regex first + OpenRouter как fallback.
|
Спецификация фичи: автоматическое создание транзакций из системных push-уведомлений банковских приложений. Платформа — Android. Парсинг — regex first + OpenRouter как fallback.
|
||||||
|
|
||||||
**Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз.** При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя.
|
**Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз.** При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
> Рекап по спецификации [notification_parsing.md](notification_parsing.md).
|
> Рекап по спецификации [notification_parsing.md](notification_parsing.md).
|
||||||
> Актуально на **2026-05-30**. Ветка `master`, `schemaVersion = 5`.
|
> Актуально на **2026-05-30**. Ветка `master`, `schemaVersion = 5`.
|
||||||
|
>
|
||||||
|
> ⚠️ **2026-07:** `account_bindings` удалена (миграция v1→v2): счёт теперь резолвится
|
||||||
|
> через правило `senderToAccount` → `source_apps.defaultAccountId` → глобальный дефолт
|
||||||
|
> (не trusted). Упоминания bindings ниже — историческое состояние.
|
||||||
|
|
||||||
Легенда: ✅ сделано · 🟡 частично · ❌ не начато.
|
Легенда: ✅ сделано · 🟡 частично · ❌ не начато.
|
||||||
|
|
||||||
|
|||||||
+16
-9
@@ -69,6 +69,7 @@
|
|||||||
|
|
||||||
"analyticsTitle": "Reports",
|
"analyticsTitle": "Reports",
|
||||||
"analyticsSubtitle": "Analytics",
|
"analyticsSubtitle": "Analytics",
|
||||||
|
"analyticsNoData": "No data for this month",
|
||||||
|
|
||||||
"accountsSubtitle": "Management",
|
"accountsSubtitle": "Management",
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@
|
|||||||
"gateCheckCurrencyKnown": "currency not recognized",
|
"gateCheckCurrencyKnown": "currency not recognized",
|
||||||
"gateCheckTypeMatchesRule": "transaction type differs from the rule",
|
"gateCheckTypeMatchesRule": "transaction type differs from the rule",
|
||||||
"gateCheckAccountResolved": "account not resolved",
|
"gateCheckAccountResolved": "account not resolved",
|
||||||
"gateCheckAccountTrusted": "ambiguous account",
|
"gateCheckAccountTrusted": "account not confirmed for this app",
|
||||||
"gateCheckAmountUnderCap": "amount is too large",
|
"gateCheckAmountUnderCap": "amount is too large",
|
||||||
"parsingRulesTile": "Parsing rules",
|
"parsingRulesTile": "Parsing rules",
|
||||||
"parsingRulesCreatedCount": "Rules created: {count}",
|
"parsingRulesCreatedCount": "Rules created: {count}",
|
||||||
@@ -317,6 +318,12 @@
|
|||||||
"inboxNoCategory": "No category",
|
"inboxNoCategory": "No category",
|
||||||
"inboxAccountUnknown": "Account unknown",
|
"inboxAccountUnknown": "Account unknown",
|
||||||
"inboxEditRuleTooltip": "Edit before saving",
|
"inboxEditRuleTooltip": "Edit before saving",
|
||||||
|
"inboxWaitingNetworkTitle": "Waiting for network",
|
||||||
|
"inboxStuckOffline": "No internet connection. Parsing will resume once you're back online.",
|
||||||
|
"inboxStuckNetwork": "Network is up, but the AI request failed. Will retry automatically.",
|
||||||
|
"parseErrorOffline": "No internet connection",
|
||||||
|
"parseErrorNetwork": "AI request failed (network error)",
|
||||||
|
"parseErrorRetryLimit": "AI retry limit reached",
|
||||||
|
|
||||||
"rulesTitle": "Parsing rules",
|
"rulesTitle": "Parsing rules",
|
||||||
"rulesSubtitle": "Settings",
|
"rulesSubtitle": "Settings",
|
||||||
@@ -432,14 +439,14 @@
|
|||||||
"sourceAppsSelfMerchantLabel": "Merchant is the app itself",
|
"sourceAppsSelfMerchantLabel": "Merchant is the app itself",
|
||||||
"sourceAppsSelfMerchantHint": "Notifications never name the seller: pick the category manually, no rules are suggested",
|
"sourceAppsSelfMerchantHint": "Notifications never name the seller: pick the category manually, no rules are suggested",
|
||||||
|
|
||||||
"appBindingsTitle": "Account bindings",
|
"appDetailEnabled": "Monitor notifications",
|
||||||
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
|
"appDetailDefaultAccount": "Default account",
|
||||||
"appBindingsAnyCard": "Any card (default)",
|
"appDetailDefaultAccountNone": "Not set",
|
||||||
"appBindingsSetDefault": "Set as default for this app",
|
"appDetailDefaultAccountHint": "New transactions from this app go to this account. It is remembered automatically the first time you confirm a message in the Inbox.",
|
||||||
"appBindingsAddTitle": "New binding",
|
"appDetailRoutingRules": "Account routing rules",
|
||||||
"appBindingsCardLabel": "Card last 4 digits",
|
"appDetailRoutingRulesEmpty": "If the app reports several accounts (card, deposit), add a rule: text pattern → account. Rules override the default account.",
|
||||||
"appBindingsCardHelper": "Leave empty to match any card",
|
"appDetailAddRule": "Add",
|
||||||
"appBindingsDefaultLabel": "Default for this app",
|
"inboxAppDefaultSet": "Account saved as the app's default",
|
||||||
|
|
||||||
"ruleKindMerchant": "Merchant → category",
|
"ruleKindMerchant": "Merchant → category",
|
||||||
"ruleKindAccount": "Sender → account",
|
"ruleKindAccount": "Sender → account",
|
||||||
|
|||||||
@@ -236,6 +236,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Аналитика'**
|
/// **'Аналитика'**
|
||||||
String get analyticsSubtitle;
|
String get analyticsSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @analyticsNoData.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Нет данных за этот месяц'**
|
||||||
|
String get analyticsNoData;
|
||||||
|
|
||||||
/// No description provided for @accountsSubtitle.
|
/// No description provided for @accountsSubtitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -995,7 +1001,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @gateCheckAccountTrusted.
|
/// No description provided for @gateCheckAccountTrusted.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'счёт неоднозначен'**
|
/// **'счёт не подтверждён для приложения'**
|
||||||
String get gateCheckAccountTrusted;
|
String get gateCheckAccountTrusted;
|
||||||
|
|
||||||
/// No description provided for @gateCheckAmountUnderCap.
|
/// No description provided for @gateCheckAmountUnderCap.
|
||||||
@@ -1520,6 +1526,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Изменить перед сохранением'**
|
/// **'Изменить перед сохранением'**
|
||||||
String get inboxEditRuleTooltip;
|
String get inboxEditRuleTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @inboxWaitingNetworkTitle.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Ждёт сети'**
|
||||||
|
String get inboxWaitingNetworkTitle;
|
||||||
|
|
||||||
|
/// No description provided for @inboxStuckOffline.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Нет подключения к интернету. Разбор продолжится, когда появится сеть.'**
|
||||||
|
String get inboxStuckOffline;
|
||||||
|
|
||||||
|
/// No description provided for @inboxStuckNetwork.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Сеть есть, но запрос к AI не прошёл. Повторим автоматически.'**
|
||||||
|
String get inboxStuckNetwork;
|
||||||
|
|
||||||
|
/// No description provided for @parseErrorOffline.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Не было соединения с интернетом'**
|
||||||
|
String get parseErrorOffline;
|
||||||
|
|
||||||
|
/// No description provided for @parseErrorNetwork.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Запрос к AI не прошёл (ошибка сети)'**
|
||||||
|
String get parseErrorNetwork;
|
||||||
|
|
||||||
|
/// No description provided for @parseErrorRetryLimit.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Лимит AI-попыток исчерпан'**
|
||||||
|
String get parseErrorRetryLimit;
|
||||||
|
|
||||||
/// No description provided for @rulesTitle.
|
/// No description provided for @rulesTitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -2036,53 +2078,53 @@ abstract class AppLocalizations {
|
|||||||
/// **'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'**
|
/// **'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'**
|
||||||
String get sourceAppsSelfMerchantHint;
|
String get sourceAppsSelfMerchantHint;
|
||||||
|
|
||||||
/// No description provided for @appBindingsTitle.
|
/// No description provided for @appDetailEnabled.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Привязки счетов'**
|
/// **'Отслеживать уведомления'**
|
||||||
String get appBindingsTitle;
|
String get appDetailEnabled;
|
||||||
|
|
||||||
/// No description provided for @appBindingsEmpty.
|
/// No description provided for @appDetailDefaultAccount.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Пока нет привязок. Добавьте, чтобы связать карту со счётом.'**
|
/// **'Счёт по умолчанию'**
|
||||||
String get appBindingsEmpty;
|
String get appDetailDefaultAccount;
|
||||||
|
|
||||||
/// No description provided for @appBindingsAnyCard.
|
/// No description provided for @appDetailDefaultAccountNone.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Любая карта (по умолчанию)'**
|
/// **'Не задан'**
|
||||||
String get appBindingsAnyCard;
|
String get appDetailDefaultAccountNone;
|
||||||
|
|
||||||
/// No description provided for @appBindingsSetDefault.
|
/// No description provided for @appDetailDefaultAccountHint.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Сделать счётом по умолчанию для приложения'**
|
/// **'Новые операции из этого приложения записываются на этот счёт. Запоминается автоматически при первом «Подтвердить» во Входящих.'**
|
||||||
String get appBindingsSetDefault;
|
String get appDetailDefaultAccountHint;
|
||||||
|
|
||||||
/// No description provided for @appBindingsAddTitle.
|
/// No description provided for @appDetailRoutingRules.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Новая привязка'**
|
/// **'Правила выбора счёта'**
|
||||||
String get appBindingsAddTitle;
|
String get appDetailRoutingRules;
|
||||||
|
|
||||||
/// No description provided for @appBindingsCardLabel.
|
/// No description provided for @appDetailRoutingRulesEmpty.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Последние 4 цифры карты'**
|
/// **'Если приложение пишет о нескольких счетах (карта, вклад), добавьте правило: паттерн в тексте → счёт. Правила важнее счёта по умолчанию.'**
|
||||||
String get appBindingsCardLabel;
|
String get appDetailRoutingRulesEmpty;
|
||||||
|
|
||||||
/// No description provided for @appBindingsCardHelper.
|
/// No description provided for @appDetailAddRule.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Оставьте пустым для любой карты'**
|
/// **'Добавить'**
|
||||||
String get appBindingsCardHelper;
|
String get appDetailAddRule;
|
||||||
|
|
||||||
/// No description provided for @appBindingsDefaultLabel.
|
/// No description provided for @inboxAppDefaultSet.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'По умолчанию для приложения'**
|
/// **'Счёт сохранён как основной для приложения'**
|
||||||
String get appBindingsDefaultLabel;
|
String get inboxAppDefaultSet;
|
||||||
|
|
||||||
/// No description provided for @ruleKindMerchant.
|
/// No description provided for @ruleKindMerchant.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get analyticsSubtitle => 'Analytics';
|
String get analyticsSubtitle => 'Analytics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get analyticsNoData => 'No data for this month';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accountsSubtitle => 'Management';
|
String get accountsSubtitle => 'Management';
|
||||||
|
|
||||||
@@ -519,7 +522,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get gateCheckAccountResolved => 'account not resolved';
|
String get gateCheckAccountResolved => 'account not resolved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get gateCheckAccountTrusted => 'ambiguous account';
|
String get gateCheckAccountTrusted => 'account not confirmed for this app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get gateCheckAmountUnderCap => 'amount is too large';
|
String get gateCheckAmountUnderCap => 'amount is too large';
|
||||||
@@ -795,6 +798,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get inboxEditRuleTooltip => 'Edit before saving';
|
String get inboxEditRuleTooltip => 'Edit before saving';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxWaitingNetworkTitle => 'Waiting for network';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxStuckOffline =>
|
||||||
|
'No internet connection. Parsing will resume once you\'re back online.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxStuckNetwork =>
|
||||||
|
'Network is up, but the AI request failed. Will retry automatically.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parseErrorOffline => 'No internet connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parseErrorNetwork => 'AI request failed (network error)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parseErrorRetryLimit => 'AI retry limit reached';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get rulesTitle => 'Parsing rules';
|
String get rulesTitle => 'Parsing rules';
|
||||||
|
|
||||||
@@ -972,7 +995,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get habitTrackingTile => 'Spending habit analysis';
|
String get habitTrackingTile => 'Spending habit analysis';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get habitObligationRequired => 'Necessary';
|
String get habitObligationRequired => 'Required';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get habitObligationOptional => 'Optional';
|
String get habitObligationOptional => 'Optional';
|
||||||
@@ -987,7 +1010,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get habitImpulseImpulsive => 'Impulse';
|
String get habitImpulseImpulsive => 'Impulse';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get habitImpulseConsidered => 'Considered';
|
String get habitImpulseConsidered => 'Thoughtful';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get habitNotMarked => 'not marked';
|
String get habitNotMarked => 'not marked';
|
||||||
@@ -999,7 +1022,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get habitScaleImpulseCaps => 'IMPULSIVENESS';
|
String get habitScaleImpulseCaps => 'IMPULSIVENESS';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get habitScaleObligationCaps => 'OBLIGATION';
|
String get habitScaleObligationCaps => 'NECESSITY';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get habitAnalysisTitle => 'Transactions';
|
String get habitAnalysisTitle => 'Transactions';
|
||||||
@@ -1070,29 +1093,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
'Notifications never name the seller: pick the category manually, no rules are suggested';
|
'Notifications never name the seller: pick the category manually, no rules are suggested';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsTitle => 'Account bindings';
|
String get appDetailEnabled => 'Monitor notifications';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsEmpty =>
|
String get appDetailDefaultAccount => 'Default account';
|
||||||
'No bindings yet. Add one to map a card to an account.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsAnyCard => 'Any card (default)';
|
String get appDetailDefaultAccountNone => 'Not set';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsSetDefault => 'Set as default for this app';
|
String get appDetailDefaultAccountHint =>
|
||||||
|
'New transactions from this app go to this account. It is remembered automatically the first time you confirm a message in the Inbox.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsAddTitle => 'New binding';
|
String get appDetailRoutingRules => 'Account routing rules';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsCardLabel => 'Card last 4 digits';
|
String get appDetailRoutingRulesEmpty =>
|
||||||
|
'If the app reports several accounts (card, deposit), add a rule: text pattern → account. Rules override the default account.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsCardHelper => 'Leave empty to match any card';
|
String get appDetailAddRule => 'Add';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsDefaultLabel => 'Default for this app';
|
String get inboxAppDefaultSet => 'Account saved as the app\'s default';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ruleKindMerchant => 'Merchant → category';
|
String get ruleKindMerchant => 'Merchant → category';
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get analyticsSubtitle => 'Аналитика';
|
String get analyticsSubtitle => 'Аналитика';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get analyticsNoData => 'Нет данных за этот месяц';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accountsSubtitle => 'Управление';
|
String get accountsSubtitle => 'Управление';
|
||||||
|
|
||||||
@@ -530,7 +533,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get gateCheckAccountResolved => 'счёт не определён';
|
String get gateCheckAccountResolved => 'счёт не определён';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get gateCheckAccountTrusted => 'счёт неоднозначен';
|
String get gateCheckAccountTrusted => 'счёт не подтверждён для приложения';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get gateCheckAmountUnderCap => 'слишком крупная сумма';
|
String get gateCheckAmountUnderCap => 'слишком крупная сумма';
|
||||||
@@ -806,6 +809,26 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get inboxEditRuleTooltip => 'Изменить перед сохранением';
|
String get inboxEditRuleTooltip => 'Изменить перед сохранением';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxWaitingNetworkTitle => 'Ждёт сети';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxStuckOffline =>
|
||||||
|
'Нет подключения к интернету. Разбор продолжится, когда появится сеть.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxStuckNetwork =>
|
||||||
|
'Сеть есть, но запрос к AI не прошёл. Повторим автоматически.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parseErrorOffline => 'Не было соединения с интернетом';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parseErrorNetwork => 'Запрос к AI не прошёл (ошибка сети)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parseErrorRetryLimit => 'Лимит AI-попыток исчерпан';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get rulesTitle => 'Правила парсинга';
|
String get rulesTitle => 'Правила парсинга';
|
||||||
|
|
||||||
@@ -1083,30 +1106,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются';
|
'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsTitle => 'Привязки счетов';
|
String get appDetailEnabled => 'Отслеживать уведомления';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsEmpty =>
|
String get appDetailDefaultAccount => 'Счёт по умолчанию';
|
||||||
'Пока нет привязок. Добавьте, чтобы связать карту со счётом.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsAnyCard => 'Любая карта (по умолчанию)';
|
String get appDetailDefaultAccountNone => 'Не задан';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsSetDefault =>
|
String get appDetailDefaultAccountHint =>
|
||||||
'Сделать счётом по умолчанию для приложения';
|
'Новые операции из этого приложения записываются на этот счёт. Запоминается автоматически при первом «Подтвердить» во Входящих.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsAddTitle => 'Новая привязка';
|
String get appDetailRoutingRules => 'Правила выбора счёта';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsCardLabel => 'Последние 4 цифры карты';
|
String get appDetailRoutingRulesEmpty =>
|
||||||
|
'Если приложение пишет о нескольких счетах (карта, вклад), добавьте правило: паттерн в тексте → счёт. Правила важнее счёта по умолчанию.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsCardHelper => 'Оставьте пустым для любой карты';
|
String get appDetailAddRule => 'Добавить';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsDefaultLabel => 'По умолчанию для приложения';
|
String get inboxAppDefaultSet => 'Счёт сохранён как основной для приложения';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ruleKindMerchant => 'Мерчант → категория';
|
String get ruleKindMerchant => 'Мерчант → категория';
|
||||||
|
|||||||
+16
-9
@@ -69,6 +69,7 @@
|
|||||||
|
|
||||||
"analyticsTitle": "Отчёты",
|
"analyticsTitle": "Отчёты",
|
||||||
"analyticsSubtitle": "Аналитика",
|
"analyticsSubtitle": "Аналитика",
|
||||||
|
"analyticsNoData": "Нет данных за этот месяц",
|
||||||
|
|
||||||
"accountsSubtitle": "Управление",
|
"accountsSubtitle": "Управление",
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@
|
|||||||
"gateCheckCurrencyKnown": "валюта не распознана",
|
"gateCheckCurrencyKnown": "валюта не распознана",
|
||||||
"gateCheckTypeMatchesRule": "тип операции отличается от правила",
|
"gateCheckTypeMatchesRule": "тип операции отличается от правила",
|
||||||
"gateCheckAccountResolved": "счёт не определён",
|
"gateCheckAccountResolved": "счёт не определён",
|
||||||
"gateCheckAccountTrusted": "счёт неоднозначен",
|
"gateCheckAccountTrusted": "счёт не подтверждён для приложения",
|
||||||
"gateCheckAmountUnderCap": "слишком крупная сумма",
|
"gateCheckAmountUnderCap": "слишком крупная сумма",
|
||||||
"parsingRulesTile": "Правила парсинга",
|
"parsingRulesTile": "Правила парсинга",
|
||||||
"parsingRulesCreatedCount": "Правил создано: {count}",
|
"parsingRulesCreatedCount": "Правил создано: {count}",
|
||||||
@@ -317,6 +318,12 @@
|
|||||||
"inboxNoCategory": "Без категории",
|
"inboxNoCategory": "Без категории",
|
||||||
"inboxAccountUnknown": "Счёт не определён",
|
"inboxAccountUnknown": "Счёт не определён",
|
||||||
"inboxEditRuleTooltip": "Изменить перед сохранением",
|
"inboxEditRuleTooltip": "Изменить перед сохранением",
|
||||||
|
"inboxWaitingNetworkTitle": "Ждёт сети",
|
||||||
|
"inboxStuckOffline": "Нет подключения к интернету. Разбор продолжится, когда появится сеть.",
|
||||||
|
"inboxStuckNetwork": "Сеть есть, но запрос к AI не прошёл. Повторим автоматически.",
|
||||||
|
"parseErrorOffline": "Не было соединения с интернетом",
|
||||||
|
"parseErrorNetwork": "Запрос к AI не прошёл (ошибка сети)",
|
||||||
|
"parseErrorRetryLimit": "Лимит AI-попыток исчерпан",
|
||||||
|
|
||||||
"rulesTitle": "Правила парсинга",
|
"rulesTitle": "Правила парсинга",
|
||||||
"rulesSubtitle": "Настройки",
|
"rulesSubtitle": "Настройки",
|
||||||
@@ -432,14 +439,14 @@
|
|||||||
"sourceAppsSelfMerchantLabel": "Мерчант — само приложение",
|
"sourceAppsSelfMerchantLabel": "Мерчант — само приложение",
|
||||||
"sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются",
|
"sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются",
|
||||||
|
|
||||||
"appBindingsTitle": "Привязки счетов",
|
"appDetailEnabled": "Отслеживать уведомления",
|
||||||
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
|
"appDetailDefaultAccount": "Счёт по умолчанию",
|
||||||
"appBindingsAnyCard": "Любая карта (по умолчанию)",
|
"appDetailDefaultAccountNone": "Не задан",
|
||||||
"appBindingsSetDefault": "Сделать счётом по умолчанию для приложения",
|
"appDetailDefaultAccountHint": "Новые операции из этого приложения записываются на этот счёт. Запоминается автоматически при первом «Подтвердить» во Входящих.",
|
||||||
"appBindingsAddTitle": "Новая привязка",
|
"appDetailRoutingRules": "Правила выбора счёта",
|
||||||
"appBindingsCardLabel": "Последние 4 цифры карты",
|
"appDetailRoutingRulesEmpty": "Если приложение пишет о нескольких счетах (карта, вклад), добавьте правило: паттерн в тексте → счёт. Правила важнее счёта по умолчанию.",
|
||||||
"appBindingsCardHelper": "Оставьте пустым для любой карты",
|
"appDetailAddRule": "Добавить",
|
||||||
"appBindingsDefaultLabel": "По умолчанию для приложения",
|
"inboxAppDefaultSet": "Счёт сохранён как основной для приложения",
|
||||||
|
|
||||||
"ruleKindMerchant": "Мерчант → категория",
|
"ruleKindMerchant": "Мерчант → категория",
|
||||||
"ruleKindAccount": "Отправитель → счёт",
|
"ruleKindAccount": "Отправитель → счёт",
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import '../../features/categories/presentation/screens/categories_list_screen.da
|
|||||||
import '../../features/categories/presentation/screens/category_form_screen.dart';
|
import '../../features/categories/presentation/screens/category_form_screen.dart';
|
||||||
import '../../features/home/presentation/screens/home_screen.dart';
|
import '../../features/home/presentation/screens/home_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/app_bindings_screen.dart';
|
|
||||||
import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/inbox_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/inbox_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/parsing_log_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/parsing_log_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/rule_editor_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/rule_editor_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/rules_list_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/rules_list_screen.dart';
|
||||||
|
import '../../features/notification_parsing/presentation/screens/source_app_detail_screen.dart';
|
||||||
import '../../features/notification_parsing/presentation/screens/source_apps_screen.dart';
|
import '../../features/notification_parsing/presentation/screens/source_apps_screen.dart';
|
||||||
import '../../features/profile/presentation/screens/profile_screen.dart';
|
import '../../features/profile/presentation/screens/profile_screen.dart';
|
||||||
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
|
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
|
||||||
@@ -75,10 +75,7 @@ GoRouter appRouter(Ref ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.categoriesList,
|
path: AppRoutes.categoriesList,
|
||||||
pageBuilder: (context, state) => _slideUpPage<void>(
|
builder: (context, state) => const CategoriesListScreen(),
|
||||||
state,
|
|
||||||
const CategoriesListScreen(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.categoryNew,
|
path: AppRoutes.categoryNew,
|
||||||
@@ -110,8 +107,7 @@ GoRouter appRouter(Ref ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.inbox,
|
path: AppRoutes.inbox,
|
||||||
pageBuilder: (context, state) =>
|
builder: (context, state) => const InboxScreen(),
|
||||||
_slideUpPage<void>(state, const InboxScreen()),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.parsingSettings,
|
path: AppRoutes.parsingSettings,
|
||||||
@@ -149,15 +145,14 @@ GoRouter appRouter(Ref ref) {
|
|||||||
builder: (context, state) => const SourceAppsScreen(),
|
builder: (context, state) => const SourceAppsScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.parsingAppBindingsPattern,
|
path: AppRoutes.parsingAppDetailPattern,
|
||||||
builder: (context, state) => AppBindingsScreen(
|
builder: (context, state) => SourceAppDetailScreen(
|
||||||
packageName: Uri.decodeComponent(state.pathParameters['pkg']!),
|
packageName: Uri.decodeComponent(state.pathParameters['pkg']!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.habitAnalysis,
|
path: AppRoutes.habitAnalysis,
|
||||||
pageBuilder: (context, state) =>
|
builder: (context, state) => const HabitAnalysisScreen(),
|
||||||
_slideUpPage<void>(state, const HabitAnalysisScreen()),
|
|
||||||
),
|
),
|
||||||
StatefulShellRoute.indexedStack(
|
StatefulShellRoute.indexedStack(
|
||||||
builder: (context, state, navigationShell) => AppScaffold(
|
builder: (context, state, navigationShell) => AppScaffold(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AppRoutes {
|
|||||||
static const parsingDebugInject = '/settings/parsing/debug';
|
static const parsingDebugInject = '/settings/parsing/debug';
|
||||||
static const parsingLog = '/settings/parsing/log';
|
static const parsingLog = '/settings/parsing/log';
|
||||||
static const parsingApps = '/settings/parsing/apps';
|
static const parsingApps = '/settings/parsing/apps';
|
||||||
static const parsingAppBindingsPattern = '/settings/parsing/apps/:pkg';
|
static const parsingAppDetailPattern = '/settings/parsing/apps/:pkg';
|
||||||
static String parsingAppBindings(String packageName) =>
|
static String parsingAppDetail(String packageName) =>
|
||||||
'/settings/parsing/apps/${Uri.encodeComponent(packageName)}';
|
'/settings/parsing/apps/${Uri.encodeComponent(packageName)}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import 'converters/enum_converters.dart';
|
import 'converters/enum_converters.dart';
|
||||||
import 'tables/users_table.dart';
|
import 'tables/users_table.dart';
|
||||||
@@ -20,13 +21,11 @@ import '../../features/notification_parsing/data/drift/converters.dart';
|
|||||||
import '../../features/notification_parsing/data/drift/tables/raw_messages_table.dart';
|
import '../../features/notification_parsing/data/drift/tables/raw_messages_table.dart';
|
||||||
import '../../features/notification_parsing/data/drift/tables/parse_rules_table.dart';
|
import '../../features/notification_parsing/data/drift/tables/parse_rules_table.dart';
|
||||||
import '../../features/notification_parsing/data/drift/tables/rule_candidates_table.dart';
|
import '../../features/notification_parsing/data/drift/tables/rule_candidates_table.dart';
|
||||||
import '../../features/notification_parsing/data/drift/tables/account_bindings_table.dart';
|
|
||||||
import '../../features/notification_parsing/data/drift/tables/source_apps_table.dart';
|
import '../../features/notification_parsing/data/drift/tables/source_apps_table.dart';
|
||||||
import '../../features/notification_parsing/data/drift/tables/transfer_pairing_blocklist_table.dart';
|
import '../../features/notification_parsing/data/drift/tables/transfer_pairing_blocklist_table.dart';
|
||||||
import '../../features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
|
import '../../features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
|
||||||
import '../../features/notification_parsing/data/drift/daos/parse_rules_dao.dart';
|
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/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';
|
import '../../features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart';
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ part 'app_database.g.dart';
|
|||||||
RawMessagesTable,
|
RawMessagesTable,
|
||||||
ParseRulesTable,
|
ParseRulesTable,
|
||||||
RuleCandidatesTable,
|
RuleCandidatesTable,
|
||||||
AccountBindingsTable,
|
|
||||||
SourceAppsTable,
|
SourceAppsTable,
|
||||||
TransferPairingBlocklistTable,
|
TransferPairingBlocklistTable,
|
||||||
],
|
],
|
||||||
@@ -58,7 +56,6 @@ part 'app_database.g.dart';
|
|||||||
RawMessagesDao,
|
RawMessagesDao,
|
||||||
ParseRulesDao,
|
ParseRulesDao,
|
||||||
RuleCandidatesDao,
|
RuleCandidatesDao,
|
||||||
AccountBindingsDao,
|
|
||||||
SourceAppsDao,
|
SourceAppsDao,
|
||||||
TransferPairingBlocklistDao,
|
TransferPairingBlocklistDao,
|
||||||
],
|
],
|
||||||
@@ -70,15 +67,106 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
@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) await _migrateV1ToV2(m);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// v1 → v2: `account_bindings` заменяются на `source_apps.defaultAccountId`
|
||||||
|
/// + правила `senderToAccount` (см. план «Упрощение связок»):
|
||||||
|
/// - default-связка приложения (или единственная) → дефолтный счёт приложения;
|
||||||
|
/// - связки по карте/телефону → contains-правило «цифры → счёт»
|
||||||
|
/// (кроме избыточных, чей счёт совпал с новым дефолтом);
|
||||||
|
/// - связки без карты/телефона и без default-флага (ambiguous) пропадают —
|
||||||
|
/// намеренно, этой ступени резолвера больше нет.
|
||||||
|
Future<void> _migrateV1ToV2(Migrator m) async {
|
||||||
|
await m.addColumn(sourceAppsTable, sourceAppsTable.defaultAccountId);
|
||||||
|
|
||||||
|
// Старые dev-сборки могли не иметь таблицы вовсе.
|
||||||
|
final hasBindings = (await customSelect(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||||
|
"AND name = 'account_bindings'",
|
||||||
|
).get())
|
||||||
|
.isNotEmpty;
|
||||||
|
if (!hasBindings) return;
|
||||||
|
|
||||||
|
final rows = await customSelect('SELECT * FROM account_bindings').get();
|
||||||
|
|
||||||
|
// Группируем по (user_id, package_name); связки без package_name (легаси
|
||||||
|
// phone-связки) конвертируются в глобальные правила отдельным проходом.
|
||||||
|
final byApp = <(String, String), List<QueryRow>>{};
|
||||||
|
final global = <QueryRow>[];
|
||||||
|
for (final r in rows) {
|
||||||
|
final pkg = r.readNullable<String>('package_name');
|
||||||
|
if (pkg == null) {
|
||||||
|
global.add(r);
|
||||||
|
} else {
|
||||||
|
byApp.putIfAbsent((r.read<String>('user_id'), pkg), () => []).add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final entry in byApp.entries) {
|
||||||
|
final (userId, pkg) = entry.key;
|
||||||
|
final group = entry.value;
|
||||||
|
final def = group.where((r) => r.read<bool>('is_default')).firstOrNull ??
|
||||||
|
(group.length == 1 ? group.single : null);
|
||||||
|
if (def != null) {
|
||||||
|
await customStatement(
|
||||||
|
'UPDATE source_apps SET default_account_id = ? '
|
||||||
|
'WHERE user_id = ? AND package_name = ?',
|
||||||
|
[def.read<String>('account_id'), userId, pkg],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final r in group) {
|
||||||
|
// Избыточное правило: счёт и так станет дефолтом приложения.
|
||||||
|
if (def != null &&
|
||||||
|
r.read<String>('account_id') == def.read<String>('account_id')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await _bindingToSenderRule(r, packageName: pkg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final r in global) {
|
||||||
|
await _bindingToSenderRule(r, packageName: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await customStatement('DROP TABLE account_bindings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Связка по карте/телефону → правило `senderToAccount` (contains по цифрам).
|
||||||
|
/// Связки без идентификатора пропускаются — паттерна для правила нет.
|
||||||
|
Future<void> _bindingToSenderRule(QueryRow r, {String? packageName}) async {
|
||||||
|
final pattern =
|
||||||
|
r.readNullable<String>('card_last4') ?? r.readNullable<String>('phone');
|
||||||
|
if (pattern == null || pattern.isEmpty) return;
|
||||||
|
await customStatement(
|
||||||
|
'INSERT INTO parse_rules '
|
||||||
|
'(id, user_id, package_name, kind, match_mode, pattern, match_count, '
|
||||||
|
'account_id, created_at) '
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
const Uuid().v4(),
|
||||||
|
r.read<String>('user_id'),
|
||||||
|
packageName,
|
||||||
|
// Конвертеры хранят enum по .name.
|
||||||
|
ParseRuleKind.senderToAccount.name,
|
||||||
|
MatchMode.contains.name,
|
||||||
|
pattern,
|
||||||
|
r.read<int>('match_count'),
|
||||||
|
r.read<String>('account_id'),
|
||||||
|
r.read<int>('created_at'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
return driftDatabase(name: 'new_budget_db');
|
return driftDatabase(name: 'new_budget_db');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// Математика календарных месяцев для аналитики.
|
||||||
|
///
|
||||||
|
/// Семантика границ повторяет `month_summary.dart` (Home): месяц — это
|
||||||
|
/// `DateTime(year, month)`, конец месяца — последняя миллисекунда перед
|
||||||
|
/// началом следующего. `settings.firstDayOfMonth` намеренно игнорируется —
|
||||||
|
/// дашборд считает по календарным месяцам.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Начало месяца: `DateTime(year, month, 1, 00:00)`.
|
||||||
|
DateTime monthStart(DateTime month) => DateTime(month.year, month.month);
|
||||||
|
|
||||||
|
/// Конец месяца: последняя миллисекунда перед началом следующего месяца.
|
||||||
|
DateTime monthEnd(DateTime month) => DateTime(month.year, month.month + 1)
|
||||||
|
.subtract(const Duration(milliseconds: 1));
|
||||||
|
|
||||||
|
/// Первый день предыдущего месяца.
|
||||||
|
DateTime previousMonth(DateTime month) =>
|
||||||
|
DateTime(month.year, month.month - 1);
|
||||||
|
|
||||||
|
/// Первый день месяца, отстоящего на [delta] месяцев (может быть < 0).
|
||||||
|
DateTime addMonths(DateTime month, int delta) =>
|
||||||
|
DateTime(month.year, month.month + delta);
|
||||||
|
|
||||||
|
/// Ключ месяца в формате `YYYY-MM` — совпадает со STRFTIME('%Y-%m') в DAO.
|
||||||
|
String monthKeyOf(DateTime month) =>
|
||||||
|
'${month.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${month.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
/// Обратное преобразование ключа `YYYY-MM` в начало месяца.
|
||||||
|
DateTime monthFromKey(String key) {
|
||||||
|
final parts = key.split('-');
|
||||||
|
return DateTime(int.parse(parts[0]), int.parse(parts[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Число дней в месяце (28–31).
|
||||||
|
int daysInMonth(DateTime month) =>
|
||||||
|
DateTime(month.year, month.month + 1, 0).day;
|
||||||
@@ -5,12 +5,13 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../app/l10n/l10n.dart';
|
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 '../../../../shared/widgets/placeholder_screen.dart';
|
|
||||||
import '../../../settings/application/settings_controller.dart';
|
import '../../../settings/application/settings_controller.dart';
|
||||||
import '../../../user/application/active_user_controller.dart';
|
import '../../../user/application/active_user_controller.dart';
|
||||||
|
import '../widgets/chart_card.dart';
|
||||||
|
import '../widgets/month_stepper.dart';
|
||||||
|
|
||||||
/// Хаб аналитики — список подэкранов. Пока единственный пункт —
|
/// Дашборд аналитики — лента карточек-графиков за выбранный месяц.
|
||||||
/// «Анализ привычек» (показывается только при включённом тумблере).
|
/// Месяц общий с Home и хабит-экраном ([MonthStepper] в AppBar).
|
||||||
class AnalyticsScreen extends ConsumerWidget {
|
class AnalyticsScreen extends ConsumerWidget {
|
||||||
const AnalyticsScreen({super.key});
|
const AnalyticsScreen({super.key});
|
||||||
|
|
||||||
@@ -25,64 +26,31 @@ class AnalyticsScreen extends ConsumerWidget {
|
|||||||
false)
|
false)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return PlaceholderScreen(
|
return Scaffold(
|
||||||
subtitle: l10n.analyticsSubtitle,
|
backgroundColor: p.paper,
|
||||||
title: l10n.analyticsTitle,
|
appBar: AppBar(
|
||||||
body: !habitEnabled
|
backgroundColor: p.paper,
|
||||||
? null
|
title: Text(l10n.analyticsTitle),
|
||||||
: ListView(
|
actions: const [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
MonthStepper(),
|
||||||
children: [
|
SizedBox(width: 12),
|
||||||
Container(
|
],
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
border: Border.all(color: p.line),
|
body: userId == null
|
||||||
borderRadius: BorderRadius.circular(14),
|
? const SizedBox.shrink()
|
||||||
),
|
: SafeArea(
|
||||||
child: _HubTile(
|
child: ListView(
|
||||||
icon: Icons.insights_outlined,
|
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||||
title: l10n.habitAnalysisTile,
|
children: [
|
||||||
onTap: () => context.push(AppRoutes.habitAnalysis),
|
if (habitEnabled)
|
||||||
),
|
ChartCard(
|
||||||
),
|
title: l10n.habitAnalysisTile,
|
||||||
],
|
leadingIcon: Icons.insights_outlined,
|
||||||
),
|
onTap: () => context.push(AppRoutes.habitAnalysis),
|
||||||
);
|
),
|
||||||
}
|
],
|
||||||
}
|
|
||||||
|
|
||||||
class _HubTile extends StatelessWidget {
|
|
||||||
const _HubTile({
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = context.palette;
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 20, color: p.ink2),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: TextStyle(fontSize: 14, color: p.ink),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import '../../../accounts/application/accounts_controller.dart';
|
|||||||
import '../../../accounts/domain/entities/account.dart';
|
import '../../../accounts/domain/entities/account.dart';
|
||||||
import '../../../categories/application/categories_controller.dart';
|
import '../../../categories/application/categories_controller.dart';
|
||||||
import '../../../categories/domain/entities/category.dart';
|
import '../../../categories/domain/entities/category.dart';
|
||||||
import '../../../home/presentation/state/selected_category_filter.dart';
|
|
||||||
import '../../../home/presentation/widgets/day_header.dart';
|
import '../../../home/presentation/widgets/day_header.dart';
|
||||||
import '../../../home/presentation/widgets/money_text.dart';
|
import '../../../home/presentation/widgets/money_text.dart';
|
||||||
import '../../../home/presentation/widgets/tx_row.dart';
|
import '../../../home/presentation/widgets/tx_row.dart';
|
||||||
import '../../../transactions/domain/entities/transaction.dart';
|
import '../../../transactions/domain/entities/transaction.dart';
|
||||||
import '../../../user/application/active_user_controller.dart';
|
import '../../../user/application/active_user_controller.dart';
|
||||||
import '../../application/habit_analysis_providers.dart';
|
import '../../application/habit_analysis_providers.dart';
|
||||||
|
import '../widgets/month_stepper.dart';
|
||||||
|
|
||||||
/// Подэкран «Анализ привычек»: фильтры по двум шкалам + список расходов за месяц.
|
/// Подэкран «Анализ привычек»: фильтры по двум шкалам + список расходов за месяц.
|
||||||
class HabitAnalysisScreen extends ConsumerWidget {
|
class HabitAnalysisScreen extends ConsumerWidget {
|
||||||
@@ -28,6 +28,14 @@ class HabitAnalysisScreen extends ConsumerWidget {
|
|||||||
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: p.paper,
|
backgroundColor: p.paper,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: p.paper,
|
||||||
|
title: Text(context.l10n.habitAnalysisTitle),
|
||||||
|
actions: const [
|
||||||
|
MonthStepper(),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
body: userId == null
|
body: userId == null
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: SafeArea(child: _Body(userId: userId)),
|
: SafeArea(child: _Body(userId: userId)),
|
||||||
@@ -57,7 +65,6 @@ class _Body extends ConsumerWidget {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(child: _HeaderBar(userId: userId)),
|
|
||||||
SliverToBoxAdapter(child: _FilterBlock(userId: userId)),
|
SliverToBoxAdapter(child: _FilterBlock(userId: userId)),
|
||||||
SliverToBoxAdapter(child: _SummaryRow(userId: userId)),
|
SliverToBoxAdapter(child: _SummaryRow(userId: userId)),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 4)),
|
const SliverToBoxAdapter(child: SizedBox(height: 4)),
|
||||||
@@ -87,99 +94,6 @@ class _Body extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Шапка: крупный заголовок + селектор месяца
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _HeaderBar extends ConsumerWidget {
|
|
||||||
const _HeaderBar({required this.userId});
|
|
||||||
|
|
||||||
final String userId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final p = context.palette;
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final locale = Localizations.localeOf(context).toString();
|
|
||||||
final month = ref.watch(selectedMonthProvider);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final isCurrentMonth = month.year == now.year && month.month == now.month;
|
|
||||||
final monthName = DateFormat.MMMM(locale).format(month);
|
|
||||||
final monthCap = '${monthName[0].toUpperCase()}${monthName.substring(1)}';
|
|
||||||
final monthTitle = '$monthCap ${month.year}';
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(4, 8, 16, 8),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
icon: Icon(Icons.close, color: p.ink),
|
|
||||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.habitAnalysisTitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: p.ink,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_NavButton(
|
|
||||||
icon: Icons.chevron_left,
|
|
||||||
color: p.ink2,
|
|
||||||
onTap: () => ref.read(selectedMonthProvider.notifier).previous(),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
monthTitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: p.ink2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!isCurrentMonth)
|
|
||||||
_NavButton(
|
|
||||||
icon: Icons.chevron_right,
|
|
||||||
color: p.ink2,
|
|
||||||
onTap: () => ref.read(selectedMonthProvider.notifier).next(),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(width: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NavButton extends StatelessWidget {
|
|
||||||
const _NavButton({
|
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final Color color;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
child: Icon(icon, size: 20, color: color),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Блок фильтров с суммами
|
// Блок фильтров с суммами
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -212,7 +126,7 @@ class _FilterBlock extends ConsumerWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../app/l10n/l10n.dart';
|
||||||
|
import '../../../../app/theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Карточка дашборда аналитики — визуальный язык `CategoryDonutCard`
|
||||||
|
/// (рамка `p.line`, радиус 14, margin 16/0/16/14, padding 14).
|
||||||
|
///
|
||||||
|
/// Заголовок + опциональный контент; при заданном [onTap] вся карточка
|
||||||
|
/// кликабельна и получает шеврон (переход на детальный экран).
|
||||||
|
class ChartCard extends StatelessWidget {
|
||||||
|
const ChartCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.leadingIcon,
|
||||||
|
this.trailing,
|
||||||
|
this.onTap,
|
||||||
|
this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final IconData? leadingIcon;
|
||||||
|
|
||||||
|
/// Виджет справа от заголовка (например, KPI-значение).
|
||||||
|
final Widget? trailing;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.palette;
|
||||||
|
|
||||||
|
final content = Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (leadingIcon != null) ...[
|
||||||
|
Icon(leadingIcon, size: 18, color: p.ink2),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: p.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
?trailing,
|
||||||
|
if (onTap != null)
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (child != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
child!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: p.line),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: onTap == null
|
||||||
|
? content
|
||||||
|
: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Заглушка контента карточки, когда за выбранный месяц нет данных.
|
||||||
|
class ChartCardEmpty extends StatelessWidget {
|
||||||
|
const ChartCardEmpty({super.key, this.height = 160});
|
||||||
|
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.palette;
|
||||||
|
return SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
context.l10n.analyticsNoData,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.ink2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:graphic/graphic.dart';
|
||||||
|
|
||||||
|
import '../../../../app/theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Единственное место маппинга [AppPalette] → примитивы `graphic`.
|
||||||
|
/// Виджеты чартов не строят PaintStyle/LabelStyle сами — берут отсюда.
|
||||||
|
class ChartTheme {
|
||||||
|
const ChartTheme(this._p);
|
||||||
|
|
||||||
|
final AppPalette _p;
|
||||||
|
|
||||||
|
// ── Цвета серий ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Color get income => _p.positive;
|
||||||
|
Color get expense => _p.negative;
|
||||||
|
Color get accent => _p.accent;
|
||||||
|
|
||||||
|
/// Вторичная/затемнённая серия (прошлый месяц, «прочее»).
|
||||||
|
Color get dim => _p.ink2;
|
||||||
|
|
||||||
|
// ── Оси ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Ось значений (деньги): подписи 10px `ink2`, сетка `line` 0.5,
|
||||||
|
/// без линии оси. Тики форматируются через [compactMinor].
|
||||||
|
AxisGuide<num> moneyAxis() => AxisGuide<num>(
|
||||||
|
label: _axisLabel(),
|
||||||
|
grid: PaintStyle(strokeColor: _p.line, strokeWidth: 0.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Ось категорий/времени: линия оси `line` 0.5, подписи 10px `ink2`,
|
||||||
|
/// без сетки.
|
||||||
|
AxisGuide<String> labelAxis() => AxisGuide<String>(
|
||||||
|
line: PaintStyle(strokeColor: _p.line, strokeWidth: 0.5),
|
||||||
|
label: _axisLabel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
LabelStyle _axisLabel() =>
|
||||||
|
LabelStyle(textStyle: TextStyle(fontSize: 10, color: _p.ink2));
|
||||||
|
|
||||||
|
// ── Тултип ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Общий стиль тултипа: тёмная плашка `ink`, текст `paper`.
|
||||||
|
/// Суммы в тултипах — полные, с ₽ (в отличие от тиков оси).
|
||||||
|
TooltipGuide tooltip({List<String>? variables, bool multiTuples = false}) =>
|
||||||
|
TooltipGuide(
|
||||||
|
backgroundColor: _p.ink,
|
||||||
|
textStyle: TextStyle(fontSize: 11, color: _p.paper),
|
||||||
|
radius: const Radius.circular(6),
|
||||||
|
elevation: 2,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
variables: variables,
|
||||||
|
multiTuples: multiTuples,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChartThemeX on BuildContext {
|
||||||
|
ChartTheme get chartTheme => ChartTheme(palette);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Общий transition для всех `Chart` — по умолчанию у graphic анимации
|
||||||
|
/// смены данных НЕТ, без явного transition шаг месяца будет мгновенным.
|
||||||
|
final Transition kChartTransition = Transition(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Компактный формат минорных единиц для тиков оси: «12k», «1.5M».
|
||||||
|
/// Без символа валюты — ₽ показываем только в тултипах.
|
||||||
|
String compactMinor(num minor) {
|
||||||
|
final rub = minor / 100;
|
||||||
|
final abs = rub.abs();
|
||||||
|
final String body;
|
||||||
|
if (abs >= 1000000) {
|
||||||
|
body = '${_trimZero(abs / 1000000)}M';
|
||||||
|
} else if (abs >= 1000) {
|
||||||
|
body = '${_trimZero(abs / 1000)}k';
|
||||||
|
} else {
|
||||||
|
body = abs.round().toString();
|
||||||
|
}
|
||||||
|
return rub < 0 ? '−$body' : body; // U+2212 minus, как в formatMinor
|
||||||
|
}
|
||||||
|
|
||||||
|
String _trimZero(double v) {
|
||||||
|
final s = v >= 10 ? v.round().toString() : v.toStringAsFixed(1);
|
||||||
|
return s.endsWith('.0') ? s.substring(0, s.length - 2) : s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../home/presentation/state/selected_category_filter.dart';
|
||||||
|
|
||||||
|
/// Селектор месяца для AppBar — двигает общий [selectedMonthProvider]
|
||||||
|
/// (тот же, что на Home и хабит-экране). Шаг вперёд скрыт на текущем месяце.
|
||||||
|
class MonthStepper extends ConsumerWidget {
|
||||||
|
const MonthStepper({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final p = context.palette;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final month = ref.watch(selectedMonthProvider);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final isCurrentMonth = month.year == now.year && month.month == now.month;
|
||||||
|
final monthName = DateFormat.MMMM(locale).format(month);
|
||||||
|
final monthCap = '${monthName[0].toUpperCase()}${monthName.substring(1)}';
|
||||||
|
final monthTitle = '$monthCap ${month.year}';
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_NavButton(
|
||||||
|
icon: Icons.chevron_left,
|
||||||
|
color: p.ink2,
|
||||||
|
onTap: () => ref.read(selectedMonthProvider.notifier).previous(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
monthTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: p.ink2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
if (!isCurrentMonth)
|
||||||
|
_NavButton(
|
||||||
|
icon: Icons.chevron_right,
|
||||||
|
color: p.ink2,
|
||||||
|
onTap: () => ref.read(selectedMonthProvider.notifier).next(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavButton extends StatelessWidget {
|
||||||
|
const _NavButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(icon, size: 20, color: color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ class CategoriesListScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: p.paper,
|
backgroundColor: p.paper,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: p.paper,
|
||||||
|
title: Text(l10n.categoriesScreenTitle),
|
||||||
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.push(AppRoutes.categoryNew),
|
onPressed: () => context.push(AppRoutes.categoryNew),
|
||||||
backgroundColor: p.accent,
|
backgroundColor: p.accent,
|
||||||
@@ -34,15 +38,7 @@ class CategoriesListScreen extends ConsumerWidget {
|
|||||||
error: (e, _) => Center(child: Text('$e')),
|
error: (e, _) => Center(child: Text('$e')),
|
||||||
data: (user) {
|
data: (user) {
|
||||||
if (user == null) return const SizedBox.shrink();
|
if (user == null) return const SizedBox.shrink();
|
||||||
return Column(
|
return _CategoriesBody(userId: user.id);
|
||||||
children: [
|
|
||||||
_Header(
|
|
||||||
title: l10n.categoriesScreenTitle,
|
|
||||||
onClose: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
Expanded(child: _CategoriesBody(userId: user.id)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -201,37 +197,3 @@ class _CategoryRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Header extends StatelessWidget {
|
|
||||||
const _Header({required this.title, required this.onClose});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final VoidCallback onClose;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = context.palette;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: onClose,
|
|
||||||
icon: Icon(Icons.close, color: p.ink),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: p.ink,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 48),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
|
|
||||||
import '../domain/entities/account_binding.dart';
|
|
||||||
import 'notification_parsing_providers.dart';
|
|
||||||
|
|
||||||
part 'account_bindings_controller.g.dart';
|
|
||||||
|
|
||||||
/// Все привязки пользователя — для экрана привязок приложения.
|
|
||||||
@riverpod
|
|
||||||
Stream<List<AccountBinding>> accountBindingsList(Ref ref, String userId) =>
|
|
||||||
ref.watch(accountBindingsRepositoryProvider).watchByUser(userId);
|
|
||||||
|
|
||||||
/// CRUD над привязками «карта/телефон → счёт».
|
|
||||||
@Riverpod(keepAlive: true)
|
|
||||||
class AccountBindingsController extends _$AccountBindingsController {
|
|
||||||
@override
|
|
||||||
AsyncValue<void> build() => const AsyncData(null);
|
|
||||||
|
|
||||||
Future<void> add({
|
|
||||||
required String userId,
|
|
||||||
required String packageName,
|
|
||||||
required String accountId,
|
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
bool isDefault = false,
|
|
||||||
}) async {
|
|
||||||
state = const AsyncLoading();
|
|
||||||
try {
|
|
||||||
final repo = ref.read(accountBindingsRepositoryProvider);
|
|
||||||
final binding = await repo.create(
|
|
||||||
userId: userId,
|
|
||||||
packageName: packageName,
|
|
||||||
accountId: accountId,
|
|
||||||
cardLast4: cardLast4,
|
|
||||||
phone: phone,
|
|
||||||
);
|
|
||||||
// Единственный default на пакет: проводим через setDefault (атомарно
|
|
||||||
// снимает флаг у прочих привязок), а не через create(isDefault: true).
|
|
||||||
if (isDefault) {
|
|
||||||
await repo.setDefault(binding.id, userId, packageName);
|
|
||||||
}
|
|
||||||
state = const AsyncData(null);
|
|
||||||
} catch (e, st) {
|
|
||||||
state = AsyncError(e, st);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setDefault(String id, String userId, String packageName) => ref
|
|
||||||
.read(accountBindingsRepositoryProvider)
|
|
||||||
.setDefault(id, userId, packageName);
|
|
||||||
|
|
||||||
Future<void> delete(String id) =>
|
|
||||||
ref.read(accountBindingsRepositoryProvider).deleteById(id);
|
|
||||||
}
|
|
||||||
@@ -65,7 +65,10 @@ class InboxController extends _$InboxController {
|
|||||||
|
|
||||||
/// «Создать правило»: транзакция + parse_rule + удаление кандидата.
|
/// «Создать правило»: транзакция + parse_rule + удаление кандидата.
|
||||||
/// Все последующие похожие сообщения auto-apply молча.
|
/// Все последующие похожие сообщения auto-apply молча.
|
||||||
Future<void> createRule({
|
///
|
||||||
|
/// Возвращает true, если выбранный счёт был записан дефолтом приложения
|
||||||
|
/// (авто-обучение) — карточка показывает SnackBar.
|
||||||
|
Future<bool> createRule({
|
||||||
required String userId,
|
required String userId,
|
||||||
required RawMessage message,
|
required RawMessage message,
|
||||||
required ParseDraft draft,
|
required ParseDraft draft,
|
||||||
@@ -74,7 +77,7 @@ class InboxController extends _$InboxController {
|
|||||||
required String merchantCanonical,
|
required String merchantCanonical,
|
||||||
required String pattern,
|
required String pattern,
|
||||||
MatchMode matchMode = MatchMode.contains,
|
MatchMode matchMode = MatchMode.contains,
|
||||||
bool bindAccount = true,
|
bool learnAppDefault = true,
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
@@ -108,8 +111,10 @@ class InboxController extends _$InboxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(rawMessagesRepositoryProvider).linkTransaction(message.id, tx.id);
|
await ref.read(rawMessagesRepositoryProvider).linkTransaction(message.id, tx.id);
|
||||||
if (bindAccount) await _maybeBind(userId, message, draft, accountId);
|
final learned = learnAppDefault &&
|
||||||
|
await _maybeSetAppDefault(userId, message, accountId);
|
||||||
state = const AsyncData(null);
|
state = const AsyncData(null);
|
||||||
|
return learned;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
state = AsyncError(e, st);
|
state = AsyncError(e, st);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -118,13 +123,15 @@ class InboxController extends _$InboxController {
|
|||||||
|
|
||||||
/// «Подтвердить разово»: только транзакция (правило не создаём),
|
/// «Подтвердить разово»: только транзакция (правило не создаём),
|
||||||
/// усиливаем кандидата для будущего предложения.
|
/// усиливаем кандидата для будущего предложения.
|
||||||
Future<void> confirmOnce({
|
///
|
||||||
|
/// Возвращает true, если счёт записан дефолтом приложения (см. [createRule]).
|
||||||
|
Future<bool> confirmOnce({
|
||||||
required String userId,
|
required String userId,
|
||||||
required RawMessage message,
|
required RawMessage message,
|
||||||
required ParseDraft draft,
|
required ParseDraft draft,
|
||||||
required String accountId,
|
required String accountId,
|
||||||
String? categoryId,
|
String? categoryId,
|
||||||
bool bindAccount = true,
|
bool learnAppDefault = true,
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
@@ -149,8 +156,10 @@ class InboxController extends _$InboxController {
|
|||||||
resolvedValue: categoryId,
|
resolvedValue: categoryId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (bindAccount) await _maybeBind(userId, message, draft, accountId);
|
final learned = learnAppDefault &&
|
||||||
|
await _maybeSetAppDefault(userId, message, accountId);
|
||||||
state = const AsyncData(null);
|
state = const AsyncData(null);
|
||||||
|
return learned;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
state = AsyncError(e, st);
|
state = AsyncError(e, st);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -389,25 +398,18 @@ class InboxController extends _$InboxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее
|
/// Авто-обучение дефолтного счёта приложения: первый Confirm по приложению
|
||||||
/// сообщение того же мерчанта прошло gate молча (account score 100).
|
/// без дефолта запоминает выбранный счёт — следующие сообщения резолвятся
|
||||||
Future<void> _maybeBind(
|
/// trusted и могут авто-применяться. true = дефолт записан только что.
|
||||||
|
Future<bool> _maybeSetAppDefault(
|
||||||
String userId,
|
String userId,
|
||||||
RawMessage message,
|
RawMessage message,
|
||||||
ParseDraft draft,
|
|
||||||
String accountId,
|
String accountId,
|
||||||
) async {
|
) async {
|
||||||
final last4 = draft.cardLast4;
|
final repo = ref.read(sourceAppsRepositoryProvider);
|
||||||
if (last4 == null) return;
|
final app = await repo.findByPackageName(userId, message.packageName);
|
||||||
final bindings = ref.read(accountBindingsRepositoryProvider);
|
if (app == null || app.defaultAccountId != null) return false;
|
||||||
final existing = await bindings.findByPackageAndCard(
|
await repo.setDefaultAccount(app.id, accountId);
|
||||||
userId, message.packageName, last4);
|
return true;
|
||||||
if (existing != null) return;
|
|
||||||
await bindings.create(
|
|
||||||
userId: userId,
|
|
||||||
packageName: message.packageName,
|
|
||||||
cardLast4: last4,
|
|
||||||
accountId: accountId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
|
|
||||||
import '../../../core/providers/database_provider.dart';
|
import '../../../core/providers/database_provider.dart';
|
||||||
import '../data/native/notification_listener_channel.dart';
|
import '../data/native/notification_listener_channel.dart';
|
||||||
import '../data/repositories/account_bindings_repository_impl.dart';
|
|
||||||
import '../data/repositories/parse_rules_repository_impl.dart';
|
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 '../data/repositories/transfer_pairing_blocklist_repository_impl.dart';
|
||||||
import '../domain/repositories/account_bindings_repository.dart';
|
|
||||||
import '../domain/repositories/parse_rules_repository.dart';
|
import '../domain/repositories/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';
|
||||||
@@ -32,11 +30,6 @@ RuleCandidatesRepository ruleCandidatesRepository(Ref ref) =>
|
|||||||
RuleCandidatesRepositoryImpl(
|
RuleCandidatesRepositoryImpl(
|
||||||
ref.watch(appDatabaseProvider).ruleCandidatesDao);
|
ref.watch(appDatabaseProvider).ruleCandidatesDao);
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
|
||||||
AccountBindingsRepository accountBindingsRepository(Ref ref) =>
|
|
||||||
AccountBindingsRepositoryImpl(
|
|
||||||
ref.watch(appDatabaseProvider).accountBindingsDao);
|
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
SourceAppsRepository sourceAppsRepository(Ref ref) =>
|
SourceAppsRepository sourceAppsRepository(Ref ref) =>
|
||||||
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
|
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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';
|
||||||
import '../domain/entities/rule_suggestion.dart';
|
import '../domain/entities/rule_suggestion.dart';
|
||||||
|
import '../domain/entities/source_app.dart';
|
||||||
import '../domain/enums.dart';
|
import '../domain/enums.dart';
|
||||||
import 'ai_providers.dart';
|
import 'ai_providers.dart';
|
||||||
import 'notification_parsing_providers.dart';
|
import 'notification_parsing_providers.dart';
|
||||||
@@ -78,8 +79,7 @@ class ParsingPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||||
await _extractViaAi(userId, msg, settings,
|
await _extractViaAi(userId, msg, settings, sourceApp: sourceApp);
|
||||||
selfMerchant: sourceApp.selfMerchant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||||
@@ -88,7 +88,7 @@ class ParsingPipeline {
|
|||||||
String userId,
|
String userId,
|
||||||
RawMessage msg,
|
RawMessage msg,
|
||||||
ParsingSettings settings, {
|
ParsingSettings settings, {
|
||||||
required bool selfMerchant,
|
required SourceApp sourceApp,
|
||||||
}) async {
|
}) async {
|
||||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
||||||
@@ -165,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, selfMerchant: selfMerchant);
|
categories: categories, sourceApp: sourceApp);
|
||||||
}
|
}
|
||||||
} on DeepSeekNetworkException {
|
} on DeepSeekNetworkException {
|
||||||
await repo.updateAfterParse(
|
await repo.updateAfterParse(
|
||||||
@@ -194,18 +194,19 @@ class ParsingPipeline {
|
|||||||
|
|
||||||
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
||||||
///
|
///
|
||||||
/// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon):
|
/// [sourceApp] — приложение-источник: `selfMerchant` («мерчант — само
|
||||||
/// AI-подсказка категории глушится, merchant→category правило не
|
/// приложение», Ozon: AI-подсказка категории глушится, merchant→category
|
||||||
/// предлагается — категорию пользователь выбирает вручную на карточке.
|
/// правило не предлагается) и `defaultAccountId` для резолвера счёта.
|
||||||
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,
|
required SourceApp sourceApp,
|
||||||
}) async {
|
}) async {
|
||||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
|
final selfMerchant = sourceApp.selfMerchant;
|
||||||
|
|
||||||
if (selfMerchant) {
|
if (selfMerchant) {
|
||||||
// «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому
|
// «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому
|
||||||
@@ -223,22 +224,18 @@ class ParsingPipeline {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
|
// 3. Разрешение счёта (§B): senderToAccount → дефолт приложения →
|
||||||
|
// глобальный дефолт (не trusted).
|
||||||
final globalDefaultAccountId = (await _ref
|
final globalDefaultAccountId = (await _ref
|
||||||
.read(accountRepositoryProvider)
|
.read(accountRepositoryProvider)
|
||||||
.watchDefault(userId)
|
.watchDefault(userId)
|
||||||
.first)
|
.first)
|
||||||
?.id;
|
?.id;
|
||||||
final resolver =
|
final resolution = resolveAccount(
|
||||||
AccountResolver(_ref.read(accountBindingsRepositoryProvider));
|
|
||||||
final resolution = await resolver.resolve(
|
|
||||||
userId: userId,
|
|
||||||
packageName: msg.packageName,
|
|
||||||
body: msg.body,
|
body: msg.body,
|
||||||
cardLast4: draft0.cardLast4,
|
|
||||||
phone: draft0.counterpartyPhone,
|
|
||||||
merchantRaw: draft0.merchantRaw,
|
merchantRaw: draft0.merchantRaw,
|
||||||
senderRules: rules,
|
senderRules: rules,
|
||||||
|
appDefaultAccountId: sourceApp.defaultAccountId,
|
||||||
globalDefaultAccountId: globalDefaultAccountId,
|
globalDefaultAccountId: globalDefaultAccountId,
|
||||||
);
|
);
|
||||||
var draft = draft0.copyWith(accountId: resolution.accountId);
|
var draft = draft0.copyWith(accountId: resolution.accountId);
|
||||||
@@ -341,7 +338,7 @@ class ParsingPipeline {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (gate.decision == GateDecision.autoApply) {
|
if (gate.decision == GateDecision.autoApply) {
|
||||||
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
|
await _autoApply(userId, msg, draft, rule!, scores);
|
||||||
} else {
|
} else {
|
||||||
await repo.updateAfterParse(
|
await repo.updateAfterParse(
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
@@ -371,7 +368,6 @@ class ParsingPipeline {
|
|||||||
ParseDraft draft,
|
ParseDraft draft,
|
||||||
rule,
|
rule,
|
||||||
FieldScores scores,
|
FieldScores scores,
|
||||||
String? bindingId,
|
|
||||||
) async {
|
) async {
|
||||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
final tx = await _ref
|
final tx = await _ref
|
||||||
@@ -404,11 +400,6 @@ class ParsingPipeline {
|
|||||||
await _ref
|
await _ref
|
||||||
.read(parseRulesRepositoryProvider)
|
.read(parseRulesRepositoryProvider)
|
||||||
.incrementMatchCount(rule.id, DateTime.now());
|
.incrementMatchCount(rule.id, DateTime.now());
|
||||||
if (bindingId != null) {
|
|
||||||
await _ref
|
|
||||||
.read(accountBindingsRepositoryProvider)
|
|
||||||
.incrementMatchCount(bindingId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Transfer pairing ───────────────────────────────────────────────────────
|
// ── Transfer pairing ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import 'parsing_settings_controller.dart';
|
|||||||
|
|
||||||
part 'parsing_worker.g.dart';
|
part 'parsing_worker.g.dart';
|
||||||
|
|
||||||
|
/// Политика бэкоффа авторетрая `pending_ai` — отдельный провайдер, чтобы
|
||||||
|
/// тесты могли ужать интервалы до миллисекунд.
|
||||||
|
typedef AiRetryBackoff = ({Duration initial, Duration max});
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AiRetryBackoff aiRetryBackoff(Ref ref) => (
|
||||||
|
initial: const Duration(seconds: 30),
|
||||||
|
max: const Duration(minutes: 15),
|
||||||
|
);
|
||||||
|
|
||||||
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
|
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
|
||||||
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
||||||
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
||||||
@@ -20,7 +30,7 @@ part 'parsing_worker.g.dart';
|
|||||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||||
/// Провайдер `keepAlive` — активируется `ref.watch` из AppScaffold.
|
/// Провайдер `keepAlive` — активируется `ref.watch` из AppScaffold.
|
||||||
///
|
///
|
||||||
/// Оба входа (pending и waitingPair) — прямые подписки на Drift-стримы
|
/// Все входы (pending, pendingAi и waitingPair) — прямые подписки на Drift-стримы
|
||||||
/// репозитория, БЕЗ промежуточных autoDispose stream-провайдеров: у тех нет
|
/// репозитория, БЕЗ промежуточных autoDispose stream-провайдеров: у тех нет
|
||||||
/// UI-слушателей, а внутренний `ref.listen` приостановленного воркера (bare
|
/// UI-слушателей, а внутренний `ref.listen` приостановленного воркера (bare
|
||||||
/// `ProviderContainer` в тестах) их не активирует — pause-семантика riverpod 3.
|
/// `ProviderContainer` в тестах) их не активирует — pause-семантика riverpod 3.
|
||||||
@@ -40,6 +50,16 @@ class ParsingWorker extends _$ParsingWorker {
|
|||||||
bool _sweepRequested = false;
|
bool _sweepRequested = false;
|
||||||
Timer? _pairTimer;
|
Timer? _pairTimer;
|
||||||
|
|
||||||
|
// Level-триггер авторетрая `pending_ai` (§7): edge-реквью по сети бессилен,
|
||||||
|
// когда connectivity считает сеть живой, а запросы фактически падают
|
||||||
|
// (транзиентный сбой, сеть без интернета). Пока есть pending_ai-строки,
|
||||||
|
// таймер с экспоненциальным бэкоффом прогоняет их через pipeline напрямую.
|
||||||
|
// Кэп реальных попыток (5) в pipeline переводит хронику в failed — цикл
|
||||||
|
// конечен; счётчик попыток при авторетрае НЕ сбрасывается (в отличие от
|
||||||
|
// ручного `resetForRetry`).
|
||||||
|
Timer? _aiRetryTimer;
|
||||||
|
Duration? _aiRetryDelay; // null = базовая задержка (бэкофф сброшен)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void build(String userId) {
|
void build(String userId) {
|
||||||
// Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
|
// Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
|
||||||
@@ -86,6 +106,27 @@ class ParsingWorker extends _$ParsingWorker {
|
|||||||
);
|
);
|
||||||
ref.onDispose(onlineSub.close);
|
ref.onDispose(onlineSub.close);
|
||||||
|
|
||||||
|
// Авторетрай pending_ai по таймеру с бэкоффом (см. комментарий у полей).
|
||||||
|
// Пустая эмиссия = очередь реально разобрана (успех, ручной ретрай или
|
||||||
|
// edge-реквью) — бэкофф сбрасывается. Прямой прогон через pipeline (без
|
||||||
|
// реквью в pending) как раз и держит строки в pending_ai до успеха:
|
||||||
|
// иначе каждый цикл опустошал бы стрим и обнулял бэкофф.
|
||||||
|
final backoff = ref.read(aiRetryBackoffProvider);
|
||||||
|
final pendingAiSub = ref
|
||||||
|
.read(rawMessagesRepositoryProvider)
|
||||||
|
.watchPendingAi(userId)
|
||||||
|
.listen((list) {
|
||||||
|
if (list.isEmpty) {
|
||||||
|
_aiRetryTimer?.cancel();
|
||||||
|
_aiRetryTimer = null;
|
||||||
|
_aiRetryDelay = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_armAiRetryTimer(userId, backoff);
|
||||||
|
});
|
||||||
|
ref.onDispose(pendingAiSub.cancel);
|
||||||
|
ref.onDispose(() => _aiRetryTimer?.cancel());
|
||||||
|
|
||||||
// Transfer pairing: каждая эмиссия waitingPair (новая половинка, склейка,
|
// Transfer pairing: каждая эмиссия waitingPair (новая половинка, склейка,
|
||||||
// релиз) запускает sweep и перевзводит таймер ближайшего дедлайна.
|
// релиз) запускает sweep и перевзводит таймер ближайшего дедлайна.
|
||||||
// Первая эмиссия покрывает старт приложения: дедлайны в БД, рестарт
|
// Первая эмиссия покрывает старт приложения: дедлайны в БД, рестарт
|
||||||
@@ -163,8 +204,44 @@ class ParsingWorker extends _$ParsingWorker {
|
|||||||
if (list.isNotEmpty) _drain(userId, list);
|
if (list.isNotEmpty) _drain(userId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Взводит таймер авторетрая, если он ещё не взведён.
|
||||||
|
void _armAiRetryTimer(String userId, AiRetryBackoff backoff) {
|
||||||
|
if (_aiRetryTimer != null) return;
|
||||||
|
_aiRetryTimer = Timer(_aiRetryDelay ?? backoff.initial, () {
|
||||||
|
_aiRetryTimer = null;
|
||||||
|
_onAiRetryTimer(userId, backoff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Срабатывание таймера авторетрая: прогоняем текущий снапшот `pending_ai`
|
||||||
|
/// через pipeline. Скипы (фича выключена / явный офлайн) перевзводят таймер
|
||||||
|
/// БЕЗ удвоения задержки — попытка не расходуется; восстановление сети
|
||||||
|
/// дополнительно ловится edge-триггером выше.
|
||||||
|
Future<void> _onAiRetryTimer(String userId, AiRetryBackoff backoff) async {
|
||||||
|
if (ref.read(parsingSettingsControllerProvider).value?.enabled == false ||
|
||||||
|
ref.read(isOnlineProvider).value == false) {
|
||||||
|
_armAiRetryTimer(userId, backoff);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Удваиваем задержку ДО прогона: неудача вернёт строки в pending_ai,
|
||||||
|
// эмиссия стрима перевзведёт таймер уже с новой задержкой.
|
||||||
|
final current = _aiRetryDelay ?? backoff.initial;
|
||||||
|
_aiRetryDelay = current * 2 <= backoff.max ? current * 2 : backoff.max;
|
||||||
|
final list = await ref
|
||||||
|
.read(rawMessagesRepositoryProvider)
|
||||||
|
.watchPendingAi(userId)
|
||||||
|
.first;
|
||||||
|
if (list.isNotEmpty) await _drain(userId, list);
|
||||||
|
}
|
||||||
|
|
||||||
/// Переводит ожидающие сети `pending_ai` обратно в `pending` (retry).
|
/// Переводит ожидающие сети `pending_ai` обратно в `pending` (retry).
|
||||||
Future<void> _requeuePendingAi(String userId) async {
|
Future<void> _requeuePendingAi(String userId) async {
|
||||||
|
// Фича выключена: pipeline всё равно no-op'нёт pending, а перевод
|
||||||
|
// pending_ai → pending терял бы карточку «ждёт сети» в Inbox. После
|
||||||
|
// обратного включения очередь подхватит таймер авторетрая.
|
||||||
|
if (ref.read(parsingSettingsControllerProvider).value?.enabled == false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||||
final list = await repo.watchPendingAi(userId).first;
|
final list = await repo.watchPendingAi(userId).first;
|
||||||
for (final m in list) {
|
for (final m in list) {
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class SourceAppsController extends _$SourceAppsController {
|
|||||||
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||||
ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value);
|
ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value);
|
||||||
|
|
||||||
|
Future<void> setDefaultAccount(String id, String? accountId) =>
|
||||||
|
ref.read(sourceAppsRepositoryProvider).setDefaultAccount(id, accountId);
|
||||||
|
|
||||||
Future<void> delete(String id) =>
|
Future<void> delete(String id) =>
|
||||||
ref.read(sourceAppsRepositoryProvider).deleteById(id);
|
ref.read(sourceAppsRepositoryProvider).deleteById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import '../../../../../core/database/app_database.dart';
|
|
||||||
import '../tables/account_bindings_table.dart';
|
|
||||||
|
|
||||||
part 'account_bindings_dao.g.dart';
|
|
||||||
|
|
||||||
@DriftAccessor(tables: [AccountBindingsTable])
|
|
||||||
class AccountBindingsDao extends DatabaseAccessor<AppDatabase>
|
|
||||||
with _$AccountBindingsDaoMixin {
|
|
||||||
AccountBindingsDao(super.db);
|
|
||||||
|
|
||||||
// ── Streams ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Все привязки пользователя — для экрана настроек парсинга.
|
|
||||||
Stream<List<AccountBindingsTableData>> watchByUser(String userId) =>
|
|
||||||
(select(accountBindingsTable)
|
|
||||||
..where((t) => t.userId.equals(userId))
|
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
|
|
||||||
.watch();
|
|
||||||
|
|
||||||
// ── Lookups (шаг 3 pipeline: account_bindings.resolve) ────────────────────
|
|
||||||
|
|
||||||
/// Точное совпадение по packageName + cardLast4 (приоритет score=100).
|
|
||||||
Future<AccountBindingsTableData?> findByPackageAndCard(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
String cardLast4,
|
|
||||||
) =>
|
|
||||||
(select(accountBindingsTable)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(userId) &
|
|
||||||
t.packageName.equals(packageName) &
|
|
||||||
t.cardLast4.equals(cardLast4))
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
/// Совпадение по bankKey + cardLast4 (score=90).
|
|
||||||
Future<AccountBindingsTableData?> findByBankKeyAndCard(
|
|
||||||
String userId,
|
|
||||||
String bankKey,
|
|
||||||
String cardLast4,
|
|
||||||
) =>
|
|
||||||
(select(accountBindingsTable)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(userId) &
|
|
||||||
t.bankKey.equals(bankKey) &
|
|
||||||
t.cardLast4.equals(cardLast4))
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
/// Совпадение по номеру телефона — для СБП-переводов.
|
|
||||||
Future<AccountBindingsTableData?> findByPhone(
|
|
||||||
String userId,
|
|
||||||
String phone,
|
|
||||||
) =>
|
|
||||||
(select(accountBindingsTable)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(userId) & t.phone.equals(phone))
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
/// Все привязки по packageName (для эвристики «один счёт банка»).
|
|
||||||
Future<List<AccountBindingsTableData>> findByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) =>
|
|
||||||
(select(accountBindingsTable)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(userId) & t.packageName.equals(packageName)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
/// Умолчательная привязка приложения (card неизвестна) — §B #4.
|
|
||||||
Future<AccountBindingsTableData?> findDefaultByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) =>
|
|
||||||
(select(accountBindingsTable)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(userId) &
|
|
||||||
t.packageName.equals(packageName) &
|
|
||||||
t.isDefault.equals(true))
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> insert(AccountBindingsTableCompanion companion) =>
|
|
||||||
into(accountBindingsTable).insert(companion);
|
|
||||||
|
|
||||||
Future<void> updateRow(AccountBindingsTableCompanion companion) =>
|
|
||||||
(update(accountBindingsTable)
|
|
||||||
..where((t) => t.id.equals(companion.id.value)))
|
|
||||||
.write(companion);
|
|
||||||
|
|
||||||
/// Инкремент matchCount при каждом успешном матче.
|
|
||||||
Future<void> incrementMatchCount(String id) => customUpdate(
|
|
||||||
'UPDATE account_bindings SET match_count = match_count + 1 WHERE id = ?',
|
|
||||||
variables: [Variable<String>(id)],
|
|
||||||
updates: {accountBindingsTable},
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Делает привязку [id] умолчательной для её пакета: снимает флаг у всех
|
|
||||||
/// прочих привязок этого packageName и выставляет у выбранной (атомарно).
|
|
||||||
Future<void> setDefault(
|
|
||||||
String id,
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) =>
|
|
||||||
transaction(() async {
|
|
||||||
await (update(accountBindingsTable)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(userId) & t.packageName.equals(packageName)))
|
|
||||||
.write(const AccountBindingsTableCompanion(isDefault: Value(false)));
|
|
||||||
await (update(accountBindingsTable)..where((t) => t.id.equals(id)))
|
|
||||||
.write(const AccountBindingsTableCompanion(isDefault: Value(true)));
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<int> deleteById(String id) =>
|
|
||||||
(delete(accountBindingsTable)..where((t) => t.id.equals(id))).go();
|
|
||||||
}
|
|
||||||
@@ -33,15 +33,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
.watch();
|
.watch();
|
||||||
|
|
||||||
/// Поток сообщений, ожидающих внимания в Inbox: распознанные (`inbox`),
|
/// Поток сообщений, ожидающих внимания в Inbox: распознанные (`inbox`),
|
||||||
/// неполные (`parsed_partial`) и ошибочные (`failed`) — последние две для
|
/// неполные (`parsed_partial`), ошибочные (`failed`) и ждущие сети
|
||||||
/// AI-ветки (§7) показываются с сырым текстом и кнопкой «попробовать снова».
|
/// (`pending_ai`) — последние три для AI-ветки (§7) показываются с сырым
|
||||||
|
/// текстом и кнопкой «попробовать снова».
|
||||||
Stream<List<RawMessagesTableData>> watchInbox(String userId) =>
|
Stream<List<RawMessagesTableData>> watchInbox(String userId) =>
|
||||||
(select(rawMessagesTable)
|
(select(rawMessagesTable)
|
||||||
..where((t) =>
|
..where((t) =>
|
||||||
t.userId.equals(userId) &
|
t.userId.equals(userId) &
|
||||||
(t.status.equalsValue(RawMessageStatus.inbox) |
|
(t.status.equalsValue(RawMessageStatus.inbox) |
|
||||||
t.status.equalsValue(RawMessageStatus.parsedPartial) |
|
t.status.equalsValue(RawMessageStatus.parsedPartial) |
|
||||||
t.status.equalsValue(RawMessageStatus.failed)))
|
t.status.equalsValue(RawMessageStatus.failed) |
|
||||||
|
t.status.equalsValue(RawMessageStatus.pendingAi)))
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||||
.watch();
|
.watch();
|
||||||
|
|
||||||
@@ -65,11 +67,12 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
..limit(limit))
|
..limit(limit))
|
||||||
.watch();
|
.watch();
|
||||||
|
|
||||||
/// Реактивный счётчик для бэджа на Home (inbox + parsed_partial + failed).
|
/// Реактивный счётчик для бэджа на Home. Список статусов должен совпадать
|
||||||
|
/// с [watchInbox] (inbox + parsed_partial + failed + pending_ai).
|
||||||
Stream<int> watchInboxCount(String userId) {
|
Stream<int> watchInboxCount(String userId) {
|
||||||
final query = customSelect(
|
final query = customSelect(
|
||||||
'SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? '
|
'SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? '
|
||||||
"AND status IN ('inbox', 'parsedPartial', 'failed')",
|
"AND status IN ('inbox', 'parsedPartial', 'failed', 'pendingAi')",
|
||||||
variables: [Variable<String>(userId)],
|
variables: [Variable<String>(userId)],
|
||||||
readsFrom: {rawMessagesTable},
|
readsFrom: {rawMessagesTable},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
|
|||||||
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||||
.write(SourceAppsTableCompanion(selfMerchant: Value(value)));
|
.write(SourceAppsTableCompanion(selfMerchant: Value(value)));
|
||||||
|
|
||||||
|
/// Основной счёт приложения; null — сброс.
|
||||||
|
Future<void> setDefaultAccount(String id, String? accountId) =>
|
||||||
|
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||||
|
.write(SourceAppsTableCompanion(defaultAccountId: Value(accountId)));
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import '../../../../../core/database/tables/users_table.dart';
|
|
||||||
import '../../../../../core/database/tables/accounts_table.dart';
|
|
||||||
|
|
||||||
/// Привязка идентификатора карты / телефона к счёту в приложении.
|
|
||||||
///
|
|
||||||
/// Используется на шаге 3 pipeline для разрешения accountId.
|
|
||||||
/// Уникальный индекс по (userId, packageName, cardLast4) через [uniqueKeys].
|
|
||||||
class AccountBindingsTable extends Table {
|
|
||||||
@override
|
|
||||||
String get tableName => 'account_bindings';
|
|
||||||
|
|
||||||
TextColumn get id => text()();
|
|
||||||
TextColumn get userId =>
|
|
||||||
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
|
||||||
|
|
||||||
TextColumn get packageName => text().nullable()();
|
|
||||||
|
|
||||||
/// Нормализованный ключ банка из bank_templates_catalog.
|
|
||||||
TextColumn get bankKey => text().nullable()();
|
|
||||||
|
|
||||||
TextColumn get cardLast4 => text().nullable()();
|
|
||||||
TextColumn get phone => text().nullable()();
|
|
||||||
|
|
||||||
TextColumn get accountId =>
|
|
||||||
text().references(AccountsTable, #id, onDelete: KeyAction.cascade)();
|
|
||||||
|
|
||||||
/// Привязка по умолчанию для этого приложения (когда карта не распознана).
|
|
||||||
/// Единственная на пакет — обеспечивается через [AccountBindingsDao.setDefault].
|
|
||||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
|
||||||
|
|
||||||
IntColumn get matchCount => integer().withDefault(const Constant(0))();
|
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<Column> get primaryKey => {id};
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Set<Column>> get uniqueKeys => [
|
|
||||||
{userId, packageName, cardLast4},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ class ParseRulesTable extends Table {
|
|||||||
/// Приложение-источник, к которому привязано правило (per-app scope).
|
/// Приложение-источник, к которому привязано правило (per-app scope).
|
||||||
/// Nullable в SQL (упрощает миграцию), но все пути создания обязаны
|
/// Nullable в SQL (упрощает миграцию), но все пути создания обязаны
|
||||||
/// передавать значение; NULL-строки матчатся в любом приложении (легаси).
|
/// передавать значение; NULL-строки матчатся в любом приложении (легаси).
|
||||||
/// Без FK на source_apps — зеркалим паттерн account_bindings.
|
/// Без FK на source_apps — удаление приложения правила не трогает.
|
||||||
TextColumn get packageName => text().nullable()();
|
TextColumn get packageName => text().nullable()();
|
||||||
|
|
||||||
/// merchantToCategory | senderToAccount | ignore
|
/// merchantToCategory | senderToAccount | ignore
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../../core/database/tables/accounts_table.dart';
|
||||||
import '../../../../../core/database/tables/users_table.dart';
|
import '../../../../../core/database/tables/users_table.dart';
|
||||||
|
|
||||||
/// Приложение-источник уведомлений, добавленное пользователем (allowlist).
|
/// Приложение-источник уведомлений, добавленное пользователем (allowlist).
|
||||||
@@ -27,6 +28,15 @@ class SourceAppsTable extends Table {
|
|||||||
/// merchant→category правило для таких источников.
|
/// merchant→category правило для таких источников.
|
||||||
BoolColumn get selfMerchant => boolean().withDefault(const Constant(false))();
|
BoolColumn get selfMerchant => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
/// Основной счёт приложения (§B): резолвер доверяет ему (trusted), правила
|
||||||
|
/// senderToAccount его переопределяют. NULL — не задан: до первого
|
||||||
|
/// «Подтвердить» в Inbox (авто-обучение) сообщения идут на глобальный
|
||||||
|
/// дефолт БЕЗ доверия. FK не энфорсится (PRAGMA foreign_keys выключен) —
|
||||||
|
/// UI и резолвер обязаны терпеть висячий id.
|
||||||
|
TextColumn get defaultAccountId => text()
|
||||||
|
.references(AccountsTable, #id, onDelete: KeyAction.setNull)
|
||||||
|
.nullable()();
|
||||||
|
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import '../../../../core/database/app_database.dart';
|
|
||||||
import '../../domain/entities/account_binding.dart';
|
|
||||||
|
|
||||||
/// Маппер: строка Drift → доменная сущность [AccountBinding].
|
|
||||||
extension AccountBindingMapper on AccountBindingsTableData {
|
|
||||||
AccountBinding toDomain() => AccountBinding(
|
|
||||||
id: id,
|
|
||||||
userId: userId,
|
|
||||||
packageName: packageName,
|
|
||||||
bankKey: bankKey,
|
|
||||||
cardLast4: cardLast4,
|
|
||||||
phone: phone,
|
|
||||||
accountId: accountId,
|
|
||||||
isDefault: isDefault,
|
|
||||||
matchCount: matchCount,
|
|
||||||
createdAt: createdAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ extension SourceAppMapper on SourceAppsTableData {
|
|||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
selfMerchant: selfMerchant,
|
selfMerchant: selfMerchant,
|
||||||
|
defaultAccountId: defaultAccountId,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import '../../domain/entities/parse_rule.dart';
|
import '../../domain/entities/parse_rule.dart';
|
||||||
import '../../domain/repositories/account_bindings_repository.dart';
|
|
||||||
import 'rule_lookup.dart';
|
import 'rule_lookup.dart';
|
||||||
|
|
||||||
/// Откуда взят разрешённый счёт (для диагностики / подсветки).
|
/// Откуда взят разрешённый счёт (для диагностики / подсветки).
|
||||||
enum AccountSource {
|
enum AccountSource { senderRule, appDefault, globalDefault, none }
|
||||||
bindingCard,
|
|
||||||
bindingPhone,
|
|
||||||
senderRule,
|
|
||||||
singleBinding,
|
|
||||||
appDefault,
|
|
||||||
ambiguous,
|
|
||||||
globalDefault,
|
|
||||||
none,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B).
|
/// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B).
|
||||||
class AccountResolution {
|
class AccountResolution {
|
||||||
@@ -21,7 +11,6 @@ class AccountResolution {
|
|||||||
required this.score,
|
required this.score,
|
||||||
required this.trusted,
|
required this.trusted,
|
||||||
required this.source,
|
required this.source,
|
||||||
this.bindingId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Разрешённый счёт (null, если ничего не нашлось).
|
/// Разрешённый счёт (null, если ничего не нашлось).
|
||||||
@@ -31,125 +20,62 @@ class AccountResolution {
|
|||||||
final int score;
|
final int score;
|
||||||
|
|
||||||
/// Можно ли доверять счёту для авто-применения. Именно это поле (а не score)
|
/// Можно ли доверять счёту для авто-применения. Именно это поле (а не score)
|
||||||
/// решает судьбу в gate: неоднозначность (#5) → false → Inbox; осознанные
|
/// решает судьбу в gate: правило и дефолт приложения — осознанный выбор
|
||||||
/// дефолты (#4, #6) → true → авто-применение разрешено.
|
/// пользователя → true; глобальный дефолт — лишь догадка → false → Inbox
|
||||||
|
/// с префиллом (первый Confirm выучит его как дефолт приложения).
|
||||||
final bool trusted;
|
final bool trusted;
|
||||||
|
|
||||||
final AccountSource source;
|
final AccountSource source;
|
||||||
|
|
||||||
/// id сработавшей привязки — для инкремента matchCount.
|
|
||||||
final String? bindingId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разрешает `accountId` по цепочке источников (§B): привязки карта/телефон,
|
/// Разрешает `accountId` по лестнице источников (§B): правило senderToAccount
|
||||||
/// `senderToAccount`-правила, per-app default и глобальный дефолт счёта.
|
/// (паттерн в теле → счёт) → дефолтный счёт приложения → глобальный дефолтный
|
||||||
class AccountResolver {
|
/// счёт (без доверия) → ничего.
|
||||||
const AccountResolver(this._bindings);
|
AccountResolution resolveAccount({
|
||||||
|
required String body,
|
||||||
final AccountBindingsRepository _bindings;
|
String? merchantRaw,
|
||||||
|
List<ParseRule> senderRules = const [],
|
||||||
Future<AccountResolution> resolve({
|
String? appDefaultAccountId,
|
||||||
required String userId,
|
String? globalDefaultAccountId,
|
||||||
required String packageName,
|
}) {
|
||||||
required String body,
|
// #1 — senderToAccount-правило (матч по телу).
|
||||||
String? cardLast4,
|
final senderRule =
|
||||||
String? phone,
|
findSenderRule(senderRules, body: body, merchantRaw: merchantRaw);
|
||||||
String? merchantRaw,
|
if (senderRule?.accountId != null) {
|
||||||
List<ParseRule> senderRules = const [],
|
return AccountResolution(
|
||||||
String? globalDefaultAccountId,
|
accountId: senderRule!.accountId,
|
||||||
}) async {
|
score: 90,
|
||||||
// #1 — binding по packageName + cardLast4.
|
trusted: true,
|
||||||
if (cardLast4 != null) {
|
source: AccountSource.senderRule,
|
||||||
final b =
|
|
||||||
await _bindings.findByPackageAndCard(userId, packageName, cardLast4);
|
|
||||||
if (b != null) {
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: b.accountId,
|
|
||||||
score: 100,
|
|
||||||
trusted: true,
|
|
||||||
source: AccountSource.bindingCard,
|
|
||||||
bindingId: b.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #1 — binding по телефону (СБП).
|
|
||||||
if (phone != null) {
|
|
||||||
final b = await _bindings.findByPhone(userId, phone);
|
|
||||||
if (b != null) {
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: b.accountId,
|
|
||||||
score: 100,
|
|
||||||
trusted: true,
|
|
||||||
source: AccountSource.bindingPhone,
|
|
||||||
bindingId: b.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #2 — senderToAccount-правило (матч по телу).
|
|
||||||
final senderRule =
|
|
||||||
findSenderRule(senderRules, body: body, merchantRaw: merchantRaw);
|
|
||||||
if (senderRule?.accountId != null) {
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: senderRule!.accountId,
|
|
||||||
score: 90,
|
|
||||||
trusted: true,
|
|
||||||
source: AccountSource.senderRule,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final byPkg = await _bindings.findByPackageName(userId, packageName);
|
|
||||||
|
|
||||||
// #3 — единственный binding по packageName.
|
|
||||||
if (byPkg.length == 1) {
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: byPkg.first.accountId,
|
|
||||||
score: 75,
|
|
||||||
trusted: true,
|
|
||||||
source: AccountSource.singleBinding,
|
|
||||||
bindingId: byPkg.first.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (byPkg.length > 1) {
|
|
||||||
// #4 — per-app default binding (card неизвестна).
|
|
||||||
final def = await _bindings.findDefaultByPackageName(userId, packageName);
|
|
||||||
if (def != null) {
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: def.accountId,
|
|
||||||
score: 70,
|
|
||||||
trusted: true,
|
|
||||||
source: AccountSource.appDefault,
|
|
||||||
bindingId: def.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// #5 — несколько bindings без default-флага → «первый», но не доверяем.
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: byPkg.first.accountId,
|
|
||||||
score: 45,
|
|
||||||
trusted: false,
|
|
||||||
source: AccountSource.ambiguous,
|
|
||||||
bindingId: byPkg.first.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #6 — глобальный Account.isDefault.
|
|
||||||
if (globalDefaultAccountId != null) {
|
|
||||||
return AccountResolution(
|
|
||||||
accountId: globalDefaultAccountId,
|
|
||||||
score: 40,
|
|
||||||
trusted: true,
|
|
||||||
source: AccountSource.globalDefault,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #7 — ничего.
|
|
||||||
return const AccountResolution(
|
|
||||||
accountId: null,
|
|
||||||
score: 15,
|
|
||||||
trusted: false,
|
|
||||||
source: AccountSource.none,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #2 — дефолтный счёт приложения (выбран пользователем / авто-обучен).
|
||||||
|
if (appDefaultAccountId != null) {
|
||||||
|
return AccountResolution(
|
||||||
|
accountId: appDefaultAccountId,
|
||||||
|
score: 75,
|
||||||
|
trusted: true,
|
||||||
|
source: AccountSource.appDefault,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #3 — глобальный Account.isDefault: префилл для Inbox, но НЕ trusted —
|
||||||
|
// авто-применение требует явно подтверждённого счёта приложения.
|
||||||
|
if (globalDefaultAccountId != null) {
|
||||||
|
return AccountResolution(
|
||||||
|
accountId: globalDefaultAccountId,
|
||||||
|
score: 40,
|
||||||
|
trusted: false,
|
||||||
|
source: AccountSource.globalDefault,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4 — ничего.
|
||||||
|
return const AccountResolution(
|
||||||
|
accountId: null,
|
||||||
|
score: 15,
|
||||||
|
trusted: false,
|
||||||
|
source: AccountSource.none,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ enum AutoApplyCheck {
|
|||||||
/// (null у легаси-правил — проверка пропускается).
|
/// (null у легаси-правил — проверка пропускается).
|
||||||
typeMatchesRule,
|
typeMatchesRule,
|
||||||
|
|
||||||
/// Счёт разрешён (binding / правило / дефолт).
|
/// Счёт разрешён (правило / дефолт приложения / глобальный дефолт).
|
||||||
accountResolved,
|
accountResolved,
|
||||||
|
|
||||||
/// Счёту можно доверять для авто-применения (не ambiguous multi-binding).
|
/// Счёту можно доверять для авто-применения: правило senderToAccount или
|
||||||
|
/// дефолт приложения; глобальный дефолт — лишь префилл, не доверяем.
|
||||||
accountTrusted,
|
accountTrusted,
|
||||||
|
|
||||||
/// Сумма ниже потолка — защита от галлюцинаций (§15).
|
/// Сумма ниже потолка — защита от галлюцинаций (§15).
|
||||||
|
|||||||
-100
@@ -1,100 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
import '../../../../core/database/app_database.dart';
|
|
||||||
import '../../domain/entities/account_binding.dart';
|
|
||||||
import '../../domain/repositories/account_bindings_repository.dart';
|
|
||||||
import '../drift/daos/account_bindings_dao.dart';
|
|
||||||
import '../mappers/account_binding_mapper.dart';
|
|
||||||
|
|
||||||
class AccountBindingsRepositoryImpl implements AccountBindingsRepository {
|
|
||||||
const AccountBindingsRepositoryImpl(this._dao);
|
|
||||||
final AccountBindingsDao _dao;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<List<AccountBinding>> watchByUser(String userId) => _dao
|
|
||||||
.watchByUser(userId)
|
|
||||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findByPackageAndCard(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
String cardLast4,
|
|
||||||
) async =>
|
|
||||||
(await _dao.findByPackageAndCard(userId, packageName, cardLast4))
|
|
||||||
?.toDomain();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findByBankKeyAndCard(
|
|
||||||
String userId,
|
|
||||||
String bankKey,
|
|
||||||
String cardLast4,
|
|
||||||
) async =>
|
|
||||||
(await _dao.findByBankKeyAndCard(userId, bankKey, cardLast4))?.toDomain();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findByPhone(String userId, String phone) async =>
|
|
||||||
(await _dao.findByPhone(userId, phone))?.toDomain();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<AccountBinding>> findByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) async =>
|
|
||||||
(await _dao.findByPackageName(userId, packageName))
|
|
||||||
.map((r) => r.toDomain())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findDefaultByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) async =>
|
|
||||||
(await _dao.findDefaultByPackageName(userId, packageName))?.toDomain();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding> create({
|
|
||||||
required String userId,
|
|
||||||
String? packageName,
|
|
||||||
String? bankKey,
|
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
required String accountId,
|
|
||||||
}) async {
|
|
||||||
final id = const Uuid().v4();
|
|
||||||
// Привязка всегда создаётся не-дефолтной; default назначается отдельно
|
|
||||||
// через [setDefault] (атомарно снимает флаг у прочих привязок пакета).
|
|
||||||
await _dao.insert(
|
|
||||||
AccountBindingsTableCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
userId: userId,
|
|
||||||
packageName: Value(packageName),
|
|
||||||
bankKey: Value(bankKey),
|
|
||||||
cardLast4: Value(cardLast4),
|
|
||||||
phone: Value(phone),
|
|
||||||
accountId: accountId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return AccountBinding(
|
|
||||||
id: id,
|
|
||||||
userId: userId,
|
|
||||||
packageName: packageName,
|
|
||||||
bankKey: bankKey,
|
|
||||||
cardLast4: cardLast4,
|
|
||||||
phone: phone,
|
|
||||||
accountId: accountId,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> incrementMatchCount(String id) => _dao.incrementMatchCount(id);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> setDefault(String id, String userId, String packageName) =>
|
|
||||||
_dao.setDefault(id, userId, packageName);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
|
||||||
}
|
|
||||||
+4
@@ -54,6 +54,10 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
|
|||||||
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||||
_dao.setSelfMerchant(id, value: value);
|
_dao.setSelfMerchant(id, value: value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setDefaultAccount(String id, String? accountId) =>
|
||||||
|
_dao.setDefaultAccount(id, accountId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
||||||
|
|
||||||
part 'account_binding.freezed.dart';
|
|
||||||
|
|
||||||
/// Привязка идентификатора карты/телефона к конкретному счёту в приложении.
|
|
||||||
///
|
|
||||||
/// Используется на шаге 3 pipeline для разрешения [accountId] из уведомления.
|
|
||||||
/// Уникальный индекс по (userId, packageName, cardLast4) в таблице.
|
|
||||||
///
|
|
||||||
/// Хотя бы одно из ([packageName], [bankKey], [cardLast4], [phone])
|
|
||||||
/// должно быть заполнено для осмысленного матча.
|
|
||||||
@freezed
|
|
||||||
abstract class AccountBinding with _$AccountBinding {
|
|
||||||
const factory AccountBinding({
|
|
||||||
required String id,
|
|
||||||
required String userId,
|
|
||||||
String? packageName,
|
|
||||||
|
|
||||||
/// Нормализованный ключ банка из bank_templates_catalog.
|
|
||||||
String? bankKey,
|
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
|
|
||||||
required String accountId,
|
|
||||||
|
|
||||||
/// Привязка по умолчанию для приложения (карта не распознана).
|
|
||||||
@Default(false) bool isDefault,
|
|
||||||
@Default(0) int matchCount,
|
|
||||||
required DateTime createdAt,
|
|
||||||
}) = _AccountBinding;
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ part 'parse_draft.freezed.dart';
|
|||||||
/// Структурированный результат разбора одного уведомления.
|
/// Структурированный результат разбора одного уведомления.
|
||||||
///
|
///
|
||||||
/// Промежуточный объект pipeline: создаётся regex-parser или ai-parser,
|
/// Промежуточный объект pipeline: создаётся regex-parser или ai-parser,
|
||||||
/// дополняется account_bindings, rule_lookup, затем передаётся в
|
/// дополняется account_resolver, rule_lookup, затем передаётся в
|
||||||
/// confidence_scorer и decision gate.
|
/// confidence_scorer и decision gate.
|
||||||
///
|
///
|
||||||
/// [amount] — минорные единицы (всегда > 0); знак определяется [type].
|
/// [amount] — минорные единицы (всегда > 0); знак определяется [type].
|
||||||
@@ -43,7 +43,7 @@ abstract class ParseDraft with _$ParseDraft {
|
|||||||
|
|
||||||
// Resolved fields — заполняются на последующих шагах pipeline:
|
// Resolved fields — заполняются на последующих шагах pipeline:
|
||||||
|
|
||||||
/// Счёт, разрешённый через account_bindings.
|
/// Счёт, разрешённый резолвером (правило / дефолт приложения / глобальный).
|
||||||
String? accountId,
|
String? accountId,
|
||||||
|
|
||||||
/// Нормализованное имя мерчанта (из правила или rule_candidate).
|
/// Нормализованное имя мерчанта (из правила или rule_candidate).
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ abstract class SourceApp with _$SourceApp {
|
|||||||
|
|
||||||
/// Уведомления не называют продавца: мерчант — само приложение (Ozon).
|
/// Уведомления не называют продавца: мерчант — само приложение (Ozon).
|
||||||
@Default(false) bool selfMerchant,
|
@Default(false) bool selfMerchant,
|
||||||
|
|
||||||
|
/// Основной счёт приложения: trusted-ступень резолвера после правил
|
||||||
|
/// senderToAccount. null — не задан (авто-обучится первым «Подтвердить»
|
||||||
|
/// в Inbox). Может висеть на удалённый счёт — UI показывает «Не задан».
|
||||||
|
String? defaultAccountId,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
}) = _SourceApp;
|
}) = _SourceApp;
|
||||||
}
|
}
|
||||||
|
|||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
import '../entities/account_binding.dart';
|
|
||||||
|
|
||||||
/// Доступ к привязкам «карта/телефон → счёт» ([AccountBinding]).
|
|
||||||
///
|
|
||||||
/// Используется на шаге 3 pipeline (account_resolver) для разрешения accountId.
|
|
||||||
abstract interface class AccountBindingsRepository {
|
|
||||||
Stream<List<AccountBinding>> watchByUser(String userId);
|
|
||||||
|
|
||||||
Future<AccountBinding?> findByPackageAndCard(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
String cardLast4,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<AccountBinding?> findByBankKeyAndCard(
|
|
||||||
String userId,
|
|
||||||
String bankKey,
|
|
||||||
String cardLast4,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<AccountBinding?> findByPhone(String userId, String phone);
|
|
||||||
|
|
||||||
Future<List<AccountBinding>> findByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Умолчательная привязка приложения (карта не распознана) — §B #4.
|
|
||||||
Future<AccountBinding?> findDefaultByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<AccountBinding> create({
|
|
||||||
required String userId,
|
|
||||||
String? packageName,
|
|
||||||
String? bankKey,
|
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
required String accountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> incrementMatchCount(String id);
|
|
||||||
|
|
||||||
/// Делает привязку умолчательной для её пакета (сбрасывает флаг у прочих).
|
|
||||||
Future<void> setDefault(String id, String userId, String packageName);
|
|
||||||
|
|
||||||
Future<void> deleteById(String id);
|
|
||||||
}
|
|
||||||
@@ -26,5 +26,8 @@ abstract interface class SourceAppsRepository {
|
|||||||
/// Флаг «мерчант — само приложение» (Ozon, маркетплейсы).
|
/// Флаг «мерчант — само приложение» (Ozon, маркетплейсы).
|
||||||
Future<void> setSelfMerchant(String id, {required bool value});
|
Future<void> setSelfMerchant(String id, {required bool value});
|
||||||
|
|
||||||
|
/// Основной счёт приложения (trusted-дефолт резолвера); null — сброс.
|
||||||
|
Future<void> setDefaultAccount(String id, String? accountId);
|
||||||
|
|
||||||
Future<void> deleteById(String id);
|
Future<void> deleteById(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../../app/l10n/l10n.dart';
|
|
||||||
import '../../../../app/theme/app_colors.dart';
|
|
||||||
import '../../../accounts/application/accounts_controller.dart';
|
|
||||||
import '../../../accounts/domain/entities/account.dart';
|
|
||||||
import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
|
|
||||||
import '../../../user/application/active_user_controller.dart';
|
|
||||||
import '../../application/account_bindings_controller.dart';
|
|
||||||
import '../../domain/entities/account_binding.dart';
|
|
||||||
|
|
||||||
/// Привязки выбранного приложения-источника: card/phone → счёт + дефолт (§A).
|
|
||||||
class AppBindingsScreen extends ConsumerWidget {
|
|
||||||
const AppBindingsScreen({super.key, required this.packageName});
|
|
||||||
|
|
||||||
final String packageName;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final p = context.palette;
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
|
||||||
|
|
||||||
if (userId == null) {
|
|
||||||
return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink());
|
|
||||||
}
|
|
||||||
|
|
||||||
final bindings = (ref.watch(accountBindingsListProvider(userId)).value ??
|
|
||||||
const <AccountBinding>[])
|
|
||||||
.where((b) => b.packageName == packageName)
|
|
||||||
.toList();
|
|
||||||
final accounts = ref.watch(accountsStreamProvider(userId)).value ??
|
|
||||||
const <Account>[];
|
|
||||||
final accountById = {for (final a in accounts) a.id: a};
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: p.paper,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: p.paper,
|
|
||||||
title: Text(l10n.appBindingsTitle),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
onPressed: () => _addBinding(context, ref, userId),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: bindings.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Text(l10n.appBindingsEmpty,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 14, color: p.ink2)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.separated(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
itemCount: bindings.length,
|
|
||||||
separatorBuilder: (_, _) => Container(height: 1, color: p.line),
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final b = bindings[i];
|
|
||||||
final accountName =
|
|
||||||
accountById[b.accountId]?.name ?? b.accountId;
|
|
||||||
final subtitle = b.cardLast4 != null
|
|
||||||
? '•••• ${b.cardLast4}'
|
|
||||||
: (b.phone ?? l10n.appBindingsAnyCard);
|
|
||||||
return ListTile(
|
|
||||||
title: Text(accountName,
|
|
||||||
style: TextStyle(fontSize: 14, color: p.ink)),
|
|
||||||
subtitle: Text(subtitle,
|
|
||||||
style: TextStyle(fontSize: 12, color: p.ink2)),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
b.isDefault ? Icons.star : Icons.star_border,
|
|
||||||
color: b.isDefault ? p.accent : p.ink2,
|
|
||||||
),
|
|
||||||
tooltip: l10n.appBindingsSetDefault,
|
|
||||||
onPressed: b.isDefault
|
|
||||||
? null
|
|
||||||
: () => ref
|
|
||||||
.read(accountBindingsControllerProvider.notifier)
|
|
||||||
.setDefault(b.id, userId, packageName),
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: Icon(Icons.delete_outline, color: p.ink2),
|
|
||||||
onPressed: () => ref
|
|
||||||
.read(accountBindingsControllerProvider.notifier)
|
|
||||||
.delete(b.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _addBinding(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
String userId,
|
|
||||||
) async {
|
|
||||||
final accountId = await showAccountPicker(context, userId: userId);
|
|
||||||
if (accountId == null || !context.mounted) return;
|
|
||||||
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final cardCtrl = TextEditingController();
|
|
||||||
var asDefault = false;
|
|
||||||
final ok = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => StatefulBuilder(
|
|
||||||
builder: (context, setState) => AlertDialog(
|
|
||||||
title: Text(l10n.appBindingsAddTitle),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: cardCtrl,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
maxLength: 4,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l10n.appBindingsCardLabel,
|
|
||||||
helperText: l10n.appBindingsCardHelper,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
CheckboxListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(l10n.appBindingsDefaultLabel),
|
|
||||||
value: asDefault,
|
|
||||||
onChanged: (v) => setState(() => asDefault = v ?? false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: Text(l10n.commonCancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
child: Text(l10n.commonAdd),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final card = cardCtrl.text.trim();
|
|
||||||
cardCtrl.dispose();
|
|
||||||
if (ok == true) {
|
|
||||||
await ref.read(accountBindingsControllerProvider.notifier).add(
|
|
||||||
userId: userId,
|
|
||||||
packageName: packageName,
|
|
||||||
accountId: accountId,
|
|
||||||
cardLast4: card.isEmpty ? null : card,
|
|
||||||
isDefault: asDefault,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ 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/inbox_controller.dart';
|
import '../../application/inbox_controller.dart';
|
||||||
|
import '../../data/parser/draft_codec.dart';
|
||||||
import '../../domain/entities/raw_message.dart';
|
import '../../domain/entities/raw_message.dart';
|
||||||
import '../widgets/inbox_card.dart';
|
import '../widgets/inbox_card.dart';
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ class InboxScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (messages.isNotEmpty)
|
// «Учесть все» имеет смысл только когда есть хоть один готовый
|
||||||
|
// черновик — карточки pendingAi/failed без draftJson applyAll скипает.
|
||||||
|
if (messages.any((m) => decodeDraftBundle(m.draftJson) != null))
|
||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../domain/entities/raw_message.dart';
|
|||||||
import '../../domain/enums.dart';
|
import '../../domain/enums.dart';
|
||||||
import '../widgets/confidence_badge.dart';
|
import '../widgets/confidence_badge.dart';
|
||||||
import '../widgets/gate_check_labels.dart';
|
import '../widgets/gate_check_labels.dart';
|
||||||
|
import '../widgets/parse_error_labels.dart';
|
||||||
|
|
||||||
/// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7).
|
/// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7).
|
||||||
const _maxParseAttempts = 5;
|
const _maxParseAttempts = 5;
|
||||||
@@ -182,7 +183,8 @@ class _LogRowState extends ConsumerState<_LogRow> {
|
|||||||
final merchant = draft?.merchantCanonical ?? draft?.merchantRaw;
|
final merchant = draft?.merchantCanonical ?? draft?.merchantRaw;
|
||||||
|
|
||||||
final canRetry = message.status == RawMessageStatus.failed ||
|
final canRetry = message.status == RawMessageStatus.failed ||
|
||||||
message.status == RawMessageStatus.ignored;
|
message.status == RawMessageStatus.ignored ||
|
||||||
|
message.status == RawMessageStatus.pendingAi;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||||
@@ -259,8 +261,15 @@ class _LogRowState extends ConsumerState<_LogRow> {
|
|||||||
if (message.lastParseError != null) ...[
|
if (message.lastParseError != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
message.lastParseError!,
|
parseErrorLabel(context, message.lastParseError!),
|
||||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
// pendingAi — ожидание, не ошибка: не пугаем красным.
|
||||||
|
color: message.status == RawMessageStatus.pendingAi
|
||||||
|
? p.ink2
|
||||||
|
: p.negative,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
maxLines: _expanded ? null : 2,
|
maxLines: _expanded ? null : 2,
|
||||||
overflow: _expanded ? null : TextOverflow.ellipsis,
|
overflow: _expanded ? null : TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|||||||
+15
-10
@@ -18,20 +18,24 @@ import '../../domain/entities/parse_rule.dart';
|
|||||||
import '../../domain/entities/source_app.dart';
|
import '../../domain/entities/source_app.dart';
|
||||||
import '../../domain/enums.dart';
|
import '../../domain/enums.dart';
|
||||||
|
|
||||||
/// Предзаполнение редактора при создании правила из Inbox.
|
/// Предзаполнение редактора при создании правила из Inbox (merchant→category
|
||||||
|
/// с паттерном/мерчантом) или с детального экрана приложения (senderToAccount
|
||||||
|
/// с одним лишь packageName). Приложение и вид правила фиксируются.
|
||||||
class RuleEditorPrefill {
|
class RuleEditorPrefill {
|
||||||
const RuleEditorPrefill({
|
const RuleEditorPrefill({
|
||||||
required this.packageName,
|
required this.packageName,
|
||||||
required this.pattern,
|
this.kind = ParseRuleKind.merchantToCategory,
|
||||||
required this.merchantCanonical,
|
this.pattern,
|
||||||
|
this.merchantCanonical,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
this.accountId,
|
this.accountId,
|
||||||
this.matchMode = MatchMode.contains,
|
this.matchMode = MatchMode.contains,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String packageName;
|
final String packageName;
|
||||||
final String pattern;
|
final ParseRuleKind kind;
|
||||||
final String merchantCanonical;
|
final String? pattern;
|
||||||
|
final String? merchantCanonical;
|
||||||
final String? categoryId;
|
final String? categoryId;
|
||||||
final String? accountId;
|
final String? accountId;
|
||||||
final MatchMode matchMode;
|
final MatchMode matchMode;
|
||||||
@@ -98,8 +102,9 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
final pf = widget.prefill;
|
final pf = widget.prefill;
|
||||||
if (pf != null) {
|
if (pf != null) {
|
||||||
_packageName = pf.packageName;
|
_packageName = pf.packageName;
|
||||||
_patternCtrl.text = pf.pattern;
|
_kind = pf.kind;
|
||||||
_merchantCtrl.text = pf.merchantCanonical;
|
_patternCtrl.text = pf.pattern ?? '';
|
||||||
|
_merchantCtrl.text = pf.merchantCanonical ?? '';
|
||||||
_matchMode = pf.matchMode;
|
_matchMode = pf.matchMode;
|
||||||
_categoryId = pf.categoryId;
|
_categoryId = pf.categoryId;
|
||||||
_accountId = pf.accountId;
|
_accountId = pf.accountId;
|
||||||
@@ -231,9 +236,9 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
children: [
|
children: [
|
||||||
// Выбор вида — только при создании из списка правил. Из Inbox
|
// Выбор вида — только при создании из списка правил. С prefill
|
||||||
// (prefill) композим всегда merchant→category: senderToAccount там
|
// вид зафиксирован источником: Inbox — merchant→category, детальный
|
||||||
// не применяется (inbox_controller создаёт правило этого вида).
|
// экран приложения — senderToAccount.
|
||||||
if (_appIsPickable) ...[
|
if (_appIsPickable) ...[
|
||||||
_KindSelector(
|
_KindSelector(
|
||||||
kind: _kind,
|
kind: _kind,
|
||||||
|
|||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../app/l10n/l10n.dart';
|
||||||
|
import '../../../../app/router/app_routes.dart';
|
||||||
|
import '../../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../accounts/application/accounts_controller.dart';
|
||||||
|
import '../../../accounts/domain/entities/account.dart';
|
||||||
|
import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
|
||||||
|
import '../../../user/application/active_user_controller.dart';
|
||||||
|
import '../../application/rules_controller.dart';
|
||||||
|
import '../../application/source_apps_controller.dart';
|
||||||
|
import '../../domain/entities/parse_rule.dart';
|
||||||
|
import '../../domain/entities/source_app.dart';
|
||||||
|
import '../../domain/enums.dart';
|
||||||
|
import 'rule_editor_screen.dart';
|
||||||
|
|
||||||
|
/// Детальный экран приложения-источника: всё про одно приложение в одном
|
||||||
|
/// месте — мониторинг, selfMerchant, дефолтный счёт и правила выбора счёта
|
||||||
|
/// (senderToAccount). Заменил экран привязок card/phone → счёт.
|
||||||
|
class SourceAppDetailScreen extends ConsumerWidget {
|
||||||
|
const SourceAppDetailScreen({super.key, required this.packageName});
|
||||||
|
|
||||||
|
final String packageName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final p = context.palette;
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
||||||
|
|
||||||
|
if (userId == null) {
|
||||||
|
return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
final app = (ref.watch(sourceAppsListProvider(userId)).value ??
|
||||||
|
const <SourceApp>[])
|
||||||
|
.where((a) => a.packageName == packageName)
|
||||||
|
.firstOrNull;
|
||||||
|
if (app == null) {
|
||||||
|
// Приложение удалено (в т.ч. только что, с этого же экрана).
|
||||||
|
return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
final ctrl = ref.read(sourceAppsControllerProvider.notifier);
|
||||||
|
final accounts = ref.watch(accountsStreamProvider(userId)).value ??
|
||||||
|
const <Account>[];
|
||||||
|
// Висячий id (счёт удалён) показываем как «Не задан».
|
||||||
|
final defaultAccount = accounts
|
||||||
|
.where((a) => a.id == app.defaultAccountId)
|
||||||
|
.firstOrNull;
|
||||||
|
final accountById = {for (final a in accounts) a.id: a};
|
||||||
|
|
||||||
|
final senderRules =
|
||||||
|
(ref.watch(parseRulesListProvider(userId)).value ?? const <ParseRule>[])
|
||||||
|
.where((r) =>
|
||||||
|
r.kind == ParseRuleKind.senderToAccount &&
|
||||||
|
r.packageName == packageName)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: p.paper,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: p.paper,
|
||||||
|
title: Text(app.displayName ?? app.packageName),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, color: p.ink2),
|
||||||
|
tooltip: l10n.commonDelete,
|
||||||
|
onPressed: () async {
|
||||||
|
await ctrl.delete(app.id);
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
children: [
|
||||||
|
if (app.displayName != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(app.packageName,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||||
|
),
|
||||||
|
_Card(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(l10n.appDetailEnabled,
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||||
|
value: app.enabled,
|
||||||
|
activeThumbColor: p.accent,
|
||||||
|
onChanged: (v) => ctrl.setEnabled(app.id, enabled: v),
|
||||||
|
),
|
||||||
|
Container(height: 1, color: p.line),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(l10n.sourceAppsSelfMerchantLabel,
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||||
|
subtitle: Text(l10n.sourceAppsSelfMerchantHint,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||||
|
value: app.selfMerchant,
|
||||||
|
activeThumbColor: p.accent,
|
||||||
|
onChanged: (v) => ctrl.setSelfMerchant(app.id, value: v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(l10n.appDetailDefaultAccount,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_Card(
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final id = await showAccountPicker(
|
||||||
|
context,
|
||||||
|
userId: userId,
|
||||||
|
currentAccountId: defaultAccount?.id,
|
||||||
|
);
|
||||||
|
if (id != null) await ctrl.setDefaultAccount(app.id, id);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 6, 6, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_balance_wallet_outlined,
|
||||||
|
size: 20, color: p.ink2),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
defaultAccount?.name ??
|
||||||
|
l10n.appDetailDefaultAccountNone,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: defaultAccount != null ? p.ink : p.ink2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (app.defaultAccountId != null)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.clear, size: 18, color: p.ink2),
|
||||||
|
tooltip: l10n.commonDelete,
|
||||||
|
onPressed: () =>
|
||||||
|
ctrl.setDefaultAccount(app.id, null),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(l10n.appDetailDefaultAccountHint,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(l10n.appDetailRoutingRules,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _addRule(context, ref, userId),
|
||||||
|
icon: Icon(Icons.add, size: 18, color: p.accent),
|
||||||
|
label: Text(l10n.appDetailAddRule,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.accent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (senderRules.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(l10n.appDetailRoutingRulesEmpty,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.ink2, height: 1.3)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_Card(
|
||||||
|
children: [
|
||||||
|
for (final (i, rule) in senderRules.indexed) ...[
|
||||||
|
if (i > 0) Container(height: 1, color: p.line),
|
||||||
|
_RuleTile(
|
||||||
|
rule: rule,
|
||||||
|
accountName: rule.accountId == null
|
||||||
|
? null
|
||||||
|
: accountById[rule.accountId]?.name,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «+»: редактор в compose-режиме с зафиксированными приложением и видом
|
||||||
|
/// senderToAccount; результат сохраняем здесь (редактор только композит).
|
||||||
|
Future<void> _addRule(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String userId,
|
||||||
|
) async {
|
||||||
|
final result = await context.push<RuleEditorResult?>(
|
||||||
|
AppRoutes.parsingRuleNew,
|
||||||
|
extra: RuleEditorPrefill(
|
||||||
|
packageName: packageName,
|
||||||
|
kind: ParseRuleKind.senderToAccount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == null) return;
|
||||||
|
await ref.read(rulesControllerProvider.notifier).create(
|
||||||
|
userId: userId,
|
||||||
|
packageName: result.packageName,
|
||||||
|
kind: result.kind,
|
||||||
|
matchMode: result.matchMode,
|
||||||
|
pattern: result.pattern,
|
||||||
|
priority: result.priority,
|
||||||
|
merchantCanonical: result.merchantCanonical.isEmpty
|
||||||
|
? null
|
||||||
|
: result.merchantCanonical,
|
||||||
|
categoryId: result.categoryId,
|
||||||
|
accountId: result.accountId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строка правила «паттерн → счёт»: тап — редактор, корзина — удаление.
|
||||||
|
class _RuleTile extends ConsumerWidget {
|
||||||
|
const _RuleTile({required this.rule, required this.accountName});
|
||||||
|
|
||||||
|
final ParseRule rule;
|
||||||
|
final String? accountName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final p = context.palette;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => context.push(AppRoutes.parsingRuleEdit(rule.id)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 6, 6, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: rule.pattern,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: rule.enabled ? p.ink : p.ink2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: ' → ${accountName ?? rule.accountId ?? '—'}',
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, size: 20, color: p.ink2),
|
||||||
|
tooltip: context.l10n.commonDelete,
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(rulesControllerProvider.notifier).delete(rule.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Card extends StatelessWidget {
|
||||||
|
const _Card({required this.children});
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.palette;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: p.line),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Column(children: children),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-59
@@ -330,74 +330,36 @@ class _AddedAppTile extends ConsumerWidget {
|
|||||||
@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 ctrl = ref.read(sourceAppsControllerProvider.notifier);
|
final ctrl = ref.read(sourceAppsControllerProvider.notifier);
|
||||||
final title = app.displayName ?? app.packageName;
|
final title = app.displayName ?? app.packageName;
|
||||||
|
|
||||||
|
// Одна строка: всё остальное (selfMerchant, дефолтный счёт, правила,
|
||||||
|
// удаление) — на детальном экране приложения.
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
|
onTap: () => context.push(AppRoutes.parsingAppDetail(app.packageName)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
|
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
|
||||||
child: Column(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
||||||
children: [
|
const SizedBox(width: 12),
|
||||||
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
Expanded(
|
||||||
const SizedBox(width: 12),
|
child: Column(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(title, 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),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../../home/presentation/widgets/money_text.dart';
|
|||||||
import '../../../transactions/presentation/screens/transaction_form_screen.dart';
|
import '../../../transactions/presentation/screens/transaction_form_screen.dart';
|
||||||
import '../../../transactions/presentation/widgets/account_picker_sheet.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/ai_providers.dart';
|
||||||
import '../../application/inbox_controller.dart';
|
import '../../application/inbox_controller.dart';
|
||||||
import '../../data/parser/draft_codec.dart';
|
import '../../data/parser/draft_codec.dart';
|
||||||
import '../../domain/entities/raw_message.dart';
|
import '../../domain/entities/raw_message.dart';
|
||||||
@@ -19,6 +20,7 @@ import '../../domain/enums.dart';
|
|||||||
import '../screens/rule_editor_screen.dart';
|
import '../screens/rule_editor_screen.dart';
|
||||||
import 'confidence_badge.dart';
|
import 'confidence_badge.dart';
|
||||||
import 'gate_check_labels.dart';
|
import 'gate_check_labels.dart';
|
||||||
|
import 'parse_error_labels.dart';
|
||||||
|
|
||||||
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
|
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
|
||||||
/// действия — «Создать правило», «Подтвердить», «Игнорировать».
|
/// действия — «Создать правило», «Подтвердить», «Игнорировать».
|
||||||
@@ -51,6 +53,9 @@ class InboxCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: message.status == RawMessageStatus.failed
|
child: message.status == RawMessageStatus.failed
|
||||||
? _FailedBody(message: message)
|
? _FailedBody(message: message)
|
||||||
|
// pendingAi — ждёт сети/AI: причина + ручной ретрай (draftJson ещё нет).
|
||||||
|
: message.status == RawMessageStatus.pendingAi
|
||||||
|
? _PendingAiBody(message: message)
|
||||||
: bundle == null
|
: bundle == null
|
||||||
? _UnrecognizedBody(message: message)
|
? _UnrecognizedBody(message: message)
|
||||||
// pairedRawMessageId → склеенная пара «Перевод между счетами».
|
// pairedRawMessageId → склеенная пара «Перевод между счетами».
|
||||||
@@ -210,7 +215,7 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
final acc = await _resolveAccount(context,
|
final acc = await _resolveAccount(context,
|
||||||
userId: userId, accountId: accountId);
|
userId: userId, accountId: accountId);
|
||||||
if (acc == null || !context.mounted) return;
|
if (acc == null || !context.mounted) return;
|
||||||
await _runReporting(
|
await _runLearning(
|
||||||
context,
|
context,
|
||||||
() => ref
|
() => ref
|
||||||
.read(inboxControllerProvider.notifier)
|
.read(inboxControllerProvider.notifier)
|
||||||
@@ -256,7 +261,7 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
final acc =
|
final acc =
|
||||||
await _resolveAccount(context, userId: userId, accountId: accountId);
|
await _resolveAccount(context, userId: userId, accountId: accountId);
|
||||||
if (acc == null || !context.mounted) return;
|
if (acc == null || !context.mounted) return;
|
||||||
await _runReporting(
|
await _runLearning(
|
||||||
context,
|
context,
|
||||||
() => ref.read(inboxControllerProvider.notifier).createRule(
|
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -292,7 +297,7 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
final acc = await _resolveAccount(context,
|
final acc = await _resolveAccount(context,
|
||||||
userId: userId, accountId: result.accountId ?? accountId);
|
userId: userId, accountId: result.accountId ?? accountId);
|
||||||
if (acc == null || !context.mounted) return;
|
if (acc == null || !context.mounted) return;
|
||||||
await _runReporting(
|
await _runLearning(
|
||||||
context,
|
context,
|
||||||
() => ref.read(inboxControllerProvider.notifier).createRule(
|
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -325,6 +330,27 @@ Future<void> _runReporting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// То же для confirmOnce/createRule: контроллер возвращает true, когда счёт
|
||||||
|
/// только что записан дефолтом приложения (авто-обучение) — сообщаем об этом
|
||||||
|
/// SnackBar'ом, дальше похожие сообщения смогут применяться автоматически.
|
||||||
|
Future<void> _runLearning(
|
||||||
|
BuildContext context,
|
||||||
|
Future<bool> Function() action,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final learned = await action();
|
||||||
|
if (learned && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.inboxAppDefaultSet)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Счёт для подтверждения: переданный [accountId] (draft/дефолт), иначе —
|
/// Счёт для подтверждения: переданный [accountId] (draft/дефолт), иначе —
|
||||||
/// пикер счёта. `null` из пикера = пользователь отменил.
|
/// пикер счёта. `null` из пикера = пользователь отменил.
|
||||||
Future<String?> _resolveAccount(
|
Future<String?> _resolveAccount(
|
||||||
@@ -549,7 +575,7 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
|||||||
accountId: draft.accountId ?? widget.defaultAccountId,
|
accountId: draft.accountId ?? widget.defaultAccountId,
|
||||||
);
|
);
|
||||||
if (acc == null || !mounted) return;
|
if (acc == null || !mounted) return;
|
||||||
await _runReporting(
|
await _runLearning(
|
||||||
context,
|
context,
|
||||||
() => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
() => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
@@ -929,7 +955,7 @@ class _FailedBody extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (message.lastParseError != null) ...[
|
if (message.lastParseError != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(message.lastParseError!,
|
Text(parseErrorLabel(context, message.lastParseError!),
|
||||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis),
|
overflow: TextOverflow.ellipsis),
|
||||||
@@ -966,6 +992,87 @@ class _FailedBody extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Карточка сообщения, ждущего сети для AI-разбора (`pendingAi`, §7): причина
|
||||||
|
/// зависания (офлайн vs сетевой сбой при живой сети), счётчик попыток, сырой
|
||||||
|
/// `body` и кнопки «Попробовать снова» / «Игнорировать». Ручной ретрай через
|
||||||
|
/// `InboxController.retry` сбрасывает счётчик попыток (`resetForRetry`).
|
||||||
|
class _PendingAiBody extends ConsumerWidget {
|
||||||
|
const _PendingAiBody({required this.message});
|
||||||
|
|
||||||
|
final RawMessage message;
|
||||||
|
|
||||||
|
/// Кэп реальных AI-попыток в pipeline (§7).
|
||||||
|
static const _maxAttempts = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final p = context.palette;
|
||||||
|
final l10n = context.l10n;
|
||||||
|
// Живое состояние сети объясняет, ЧЕМУ сообщение висит: явный офлайн —
|
||||||
|
// ждём сеть; иначе сеть числится живой, но запрос не прошёл.
|
||||||
|
final offline = ref.watch(isOnlineProvider).value == false;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
offline ? Icons.cloud_off_outlined : Icons.hourglass_top,
|
||||||
|
size: 18,
|
||||||
|
color: p.ink2,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(l10n.inboxWaitingNetworkTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14, fontWeight: FontWeight.w600, color: p.ink)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
offline ? l10n.inboxStuckOffline : l10n.inboxStuckNetwork,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||||
|
),
|
||||||
|
if (message.parseAttemptCount > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.parsingDetailAttempt(message.parseAttemptCount, _maxAttempts),
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(message.body,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SecondaryButton(
|
||||||
|
icon: Icons.refresh,
|
||||||
|
label: l10n.inboxRetry,
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(inboxControllerProvider.notifier).retry(message),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _SecondaryButton(
|
||||||
|
icon: Icons.close,
|
||||||
|
label: l10n.inboxIgnore,
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(inboxControllerProvider.notifier).ignore(message),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Акцентная сплит-кнопка «действие | ✎»: основной сегмент выполняет действие
|
/// Акцентная сплит-кнопка «действие | ✎»: основной сегмент выполняет действие
|
||||||
/// ([onTap] == null — сегмент неактивен и приглушён), карандаш всегда активен
|
/// ([onTap] == null — сегмент неактивен и приглушён), карандаш всегда активен
|
||||||
/// и открывает редактор/форму. Используется для «Создать правило» и
|
/// и открывает редактор/форму. Используется для «Создать правило» и
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../../../../app/l10n/l10n.dart';
|
||||||
|
|
||||||
|
/// Локализованная причина сбоя разбора по сырому значению `lastParseError`
|
||||||
|
/// (пишется pipeline'ом) — для журнала парсинга и карточек Inbox.
|
||||||
|
String parseErrorLabel(BuildContext context, String raw) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
switch (raw) {
|
||||||
|
case 'offline':
|
||||||
|
return l10n.parseErrorOffline;
|
||||||
|
case 'network':
|
||||||
|
return l10n.parseErrorNetwork;
|
||||||
|
case 'AI retry limit reached':
|
||||||
|
return l10n.parseErrorRetryLimit;
|
||||||
|
default:
|
||||||
|
// auth/API-ошибки несут полезный свободный текст — показываем как есть.
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -115,20 +116,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
// Demo-seeding is dev-only tooling: hidden in release builds so it
|
||||||
Container(
|
// can't be tapped by accident in the production app.
|
||||||
decoration: BoxDecoration(
|
if (kDebugMode) ...[
|
||||||
border: Border.all(color: p.line),
|
const SizedBox(height: 12),
|
||||||
borderRadius: BorderRadius.circular(14),
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: p.line),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: _NavRow(
|
||||||
|
icon: Icons.auto_fix_high_outlined,
|
||||||
|
title: l10n.profileSeedDemoTile,
|
||||||
|
loading: _seedingDemo,
|
||||||
|
onTap: userId == null ? () {} : () => _seedDemo(userId),
|
||||||
|
enabled: userId != null && !_seedingDemo,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: _NavRow(
|
],
|
||||||
icon: Icons.auto_fix_high_outlined,
|
|
||||||
title: l10n.profileSeedDemoTile,
|
|
||||||
loading: _seedingDemo,
|
|
||||||
onTap: userId == null ? () {} : () => _seedDemo(userId),
|
|
||||||
enabled: userId != null && !_seedingDemo,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
l10n.profileHint,
|
l10n.profileHint,
|
||||||
|
|||||||
@@ -453,6 +453,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.0"
|
version: "8.1.0"
|
||||||
|
graphic:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: graphic
|
||||||
|
sha256: f0028af737f7fdd5fd50c043af85242d72638ae040a820470208bc885a229d04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -685,6 +693,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_drawing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_drawing
|
||||||
|
sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+8
-1
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
|
|
||||||
# Charts
|
# Charts
|
||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
|
graphic: ^2.7.0
|
||||||
|
|
||||||
# AI fallback (DeepSeek)
|
# AI fallback (DeepSeek)
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
@@ -70,8 +71,14 @@ flutter:
|
|||||||
|
|
||||||
# App launcher icons — regenerate with:
|
# App launcher icons — regenerate with:
|
||||||
# dart run flutter_launcher_icons
|
# dart run flutter_launcher_icons
|
||||||
|
# Adaptive (API 26+): flat blue background matching the artwork's own background,
|
||||||
|
# foreground inset so the cat/coins stay inside the launcher mask's safe zone.
|
||||||
|
# image_path is the legacy fallback for pre-26 devices.
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
ios: false
|
ios: false
|
||||||
|
min_sdk_android: 21
|
||||||
image_path: "assets/icon/app_icon.png"
|
image_path: "assets/icon/app_icon.png"
|
||||||
remove_alpha_ios: true
|
adaptive_icon_background: "#59C9FD"
|
||||||
|
adaptive_icon_foreground: "assets/icon/app_icon.png"
|
||||||
|
adaptive_icon_foreground_inset: 14
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:new_budget/src/core/database/app_database.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||||
|
|
||||||
|
/// Миграция v1 → v2: `account_bindings` → `source_apps.default_account_id`
|
||||||
|
/// + правила `senderToAccount` (см. `AppDatabase._migrateV1ToV2`).
|
||||||
|
///
|
||||||
|
/// Сценарий: raw-схема v1 + фикстуры связок заливаются в `setup:` (до drift),
|
||||||
|
/// `PRAGMA user_version = 1` заставляет drift выполнить onUpgrade при
|
||||||
|
/// открытии. Дальше проверяем результат уже через API v2.
|
||||||
|
|
||||||
|
const _userId = 'u1';
|
||||||
|
const _sber = 'ru.sberbankmobile';
|
||||||
|
const _tinkoff = 'com.idamob.tinkoff.android';
|
||||||
|
const _vtb = 'ru.vtb24.mobilebanking.android';
|
||||||
|
|
||||||
|
void _createV1Schema(dynamic raw) {
|
||||||
|
raw.execute('''
|
||||||
|
CREATE TABLE source_apps (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
package_name TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
self_merchant INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
UNIQUE (user_id, package_name)
|
||||||
|
);''');
|
||||||
|
raw.execute('''
|
||||||
|
CREATE TABLE parse_rules (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
package_name TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
match_mode TEXT NOT NULL DEFAULT 'contains',
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
match_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_match_at INTEGER,
|
||||||
|
tx_type TEXT,
|
||||||
|
merchant_canonical TEXT,
|
||||||
|
category_id TEXT,
|
||||||
|
account_id TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
);''');
|
||||||
|
raw.execute('''
|
||||||
|
CREATE TABLE account_bindings (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
package_name TEXT,
|
||||||
|
bank_key TEXT,
|
||||||
|
card_last4 TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
match_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
UNIQUE (user_id, package_name, card_last4)
|
||||||
|
);''');
|
||||||
|
|
||||||
|
raw.execute(
|
||||||
|
'INSERT INTO source_apps (id, user_id, package_name) VALUES '
|
||||||
|
"('src-sber', '$_userId', '$_sber'), "
|
||||||
|
"('src-tinkoff', '$_userId', '$_tinkoff'), "
|
||||||
|
"('src-vtb', '$_userId', '$_vtb')");
|
||||||
|
|
||||||
|
// Сбер: default-связка по карте 1234 + вторая карта 5678 → отдельный счёт.
|
||||||
|
raw.execute(
|
||||||
|
'INSERT INTO account_bindings '
|
||||||
|
'(id, user_id, package_name, card_last4, account_id, is_default, match_count) VALUES '
|
||||||
|
"('b1', '$_userId', '$_sber', '1234', 'acc-1', 1, 3), "
|
||||||
|
"('b2', '$_userId', '$_sber', '5678', 'acc-2', 0, 7)");
|
||||||
|
// Тинькофф: единственная связка без карты и default-флага.
|
||||||
|
raw.execute(
|
||||||
|
'INSERT INTO account_bindings '
|
||||||
|
'(id, user_id, package_name, account_id) VALUES '
|
||||||
|
"('b3', '$_userId', '$_tinkoff', 'acc-3')");
|
||||||
|
// ВТБ: две карточные связки без default — дефолт не выводится, оба правила.
|
||||||
|
raw.execute(
|
||||||
|
'INSERT INTO account_bindings '
|
||||||
|
'(id, user_id, package_name, card_last4, account_id) VALUES '
|
||||||
|
"('b4', '$_userId', '$_vtb', '1111', 'acc-5'), "
|
||||||
|
"('b5', '$_userId', '$_vtb', '2222', 'acc-6')");
|
||||||
|
// Легаси phone-связка без package_name → глобальное правило.
|
||||||
|
raw.execute(
|
||||||
|
'INSERT INTO account_bindings '
|
||||||
|
'(id, user_id, phone, account_id) VALUES '
|
||||||
|
"('b6', '$_userId', '+79990001122', 'acc-4')");
|
||||||
|
|
||||||
|
raw.execute('PRAGMA user_version = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory(setup: _createV1Schema));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('default-связка (или единственная) → source_apps.default_account_id',
|
||||||
|
() async {
|
||||||
|
final apps = await db.select(db.sourceAppsTable).get();
|
||||||
|
final byId = {for (final a in apps) a.id: a};
|
||||||
|
|
||||||
|
expect(byId['src-sber']!.defaultAccountId, 'acc-1',
|
||||||
|
reason: 'is_default=1 → дефолт приложения');
|
||||||
|
expect(byId['src-tinkoff']!.defaultAccountId, 'acc-3',
|
||||||
|
reason: 'единственная связка → дефолт приложения');
|
||||||
|
expect(byId['src-vtb']!.defaultAccountId, isNull,
|
||||||
|
reason: 'несколько связок без default — дефолт не выводится');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('карточные/phone-связки → правила senderToAccount, избыточные — скип',
|
||||||
|
() async {
|
||||||
|
final rules = await db.select(db.parseRulesTable).get();
|
||||||
|
expect(rules.every((r) => r.kind == ParseRuleKind.senderToAccount), isTrue);
|
||||||
|
expect(rules.every((r) => r.matchMode == MatchMode.contains), isTrue);
|
||||||
|
expect(rules.every((r) => r.enabled), isTrue,
|
||||||
|
reason: 'SQL-дефолт enabled=1 должен примениться');
|
||||||
|
expect(rules.every((r) => r.txType == null), isTrue,
|
||||||
|
reason: 'tx_type NULL — gate пропускает проверку типа');
|
||||||
|
|
||||||
|
final byPattern = {for (final r in rules) r.pattern: r};
|
||||||
|
expect(byPattern.keys.toSet(), {'5678', '1111', '2222', '+79990001122'},
|
||||||
|
reason: '«1234» не мигрирует: счёт совпал с новым дефолтом приложения');
|
||||||
|
|
||||||
|
expect(byPattern['5678']!.packageName, _sber);
|
||||||
|
expect(byPattern['5678']!.accountId, 'acc-2');
|
||||||
|
expect(byPattern['5678']!.matchCount, 7,
|
||||||
|
reason: 'match_count переносится из связки');
|
||||||
|
|
||||||
|
expect(byPattern['1111']!.accountId, 'acc-5');
|
||||||
|
expect(byPattern['2222']!.accountId, 'acc-6');
|
||||||
|
|
||||||
|
expect(byPattern['+79990001122']!.packageName, isNull,
|
||||||
|
reason: 'phone-связка без package_name → глобальное правило (легаси)');
|
||||||
|
expect(byPattern['+79990001122']!.accountId, 'acc-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('таблица account_bindings удалена', () async {
|
||||||
|
final left = await db
|
||||||
|
.customSelect(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||||
|
"AND name = 'account_bindings'",
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
expect(left, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('свежая БД (onCreate) сразу содержит default_account_id и без bindings',
|
||||||
|
() async {
|
||||||
|
final fresh = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(fresh.close);
|
||||||
|
|
||||||
|
// Колонка есть → insert с defaultAccountId проходит.
|
||||||
|
await fresh.usersDao.insertUser(
|
||||||
|
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||||
|
);
|
||||||
|
await fresh.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||||
|
id: 'src-1',
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _sber,
|
||||||
|
));
|
||||||
|
await fresh.sourceAppsDao.setDefaultAccount('src-1', 'acc-1');
|
||||||
|
final row = await fresh.sourceAppsDao.findById('src-1');
|
||||||
|
expect(row!.defaultAccountId, 'acc-1');
|
||||||
|
|
||||||
|
final bindings = await fresh
|
||||||
|
.customSelect(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||||
|
"AND name = 'account_bindings'",
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
expect(bindings, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:new_budget/l10n/app_localizations.dart';
|
||||||
|
import 'package:new_budget/src/app/theme/app_theme.dart';
|
||||||
|
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||||
|
import 'package:new_budget/src/features/analytics/domain/month_math.dart';
|
||||||
|
import 'package:new_budget/src/features/analytics/presentation/screens/analytics_screen.dart';
|
||||||
|
import 'package:new_budget/src/features/home/presentation/state/selected_category_filter.dart';
|
||||||
|
import 'package:new_budget/src/features/settings/application/settings_controller.dart';
|
||||||
|
import 'package:new_budget/src/features/settings/domain/entities/settings.dart';
|
||||||
|
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
|
||||||
|
import 'package:new_budget/src/features/user/domain/entities/user.dart';
|
||||||
|
|
||||||
|
// ─── Fakes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _userId = 'test-uid';
|
||||||
|
|
||||||
|
class FakeActiveUserController extends ActiveUserController {
|
||||||
|
@override
|
||||||
|
Future<User?> build() async =>
|
||||||
|
User(id: _userId, name: 'Test', createdAt: DateTime(2024));
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeSettingsController extends SettingsController {
|
||||||
|
FakeSettingsController({required this.habitEnabled});
|
||||||
|
final bool habitEnabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Settings> build(String userId) async => Settings(
|
||||||
|
userId: _userId,
|
||||||
|
baseCurrency: 'RUB',
|
||||||
|
themeMode: AppThemeMode.system,
|
||||||
|
locale: 'ru',
|
||||||
|
firstDayOfMonth: 1,
|
||||||
|
habitTrackingEnabled: habitEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScreen({required bool habitEnabled}) => ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeUserControllerProvider
|
||||||
|
.overrideWith(() => FakeActiveUserController()),
|
||||||
|
settingsControllerProvider(_userId)
|
||||||
|
.overrideWith(() => FakeSettingsController(
|
||||||
|
habitEnabled: habitEnabled,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const AnalyticsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderContainer _container(WidgetTester tester) =>
|
||||||
|
ProviderScope.containerOf(tester.element(find.byType(AnalyticsScreen)));
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('хабит-карточка видна при включённом трекинге', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildScreen(habitEnabled: true));
|
||||||
|
await tester.pump(); // future-провайдеры разрешаются
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Habit analysis'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('хабит-карточка скрыта при выключенном трекинге',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_buildScreen(habitEnabled: false));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Habit analysis'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('шаг месяца в AppBar двигает общий selectedMonthProvider',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_buildScreen(habitEnabled: false));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final container = _container(tester);
|
||||||
|
final initial = container.read(selectedMonthProvider);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.chevron_left));
|
||||||
|
await tester.pump();
|
||||||
|
expect(container.read(selectedMonthProvider), previousMonth(initial));
|
||||||
|
|
||||||
|
// На прошлом месяце появляется шаг вперёд — возвращаемся.
|
||||||
|
await tester.tap(find.byIcon(Icons.chevron_right));
|
||||||
|
await tester.pump();
|
||||||
|
expect(container.read(selectedMonthProvider), initial);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ void main() {
|
|||||||
GoogleFonts.config.allowRuntimeFetching = false;
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('по умолчанию: оба «All» активны, чип «Necessary» виден',
|
testWidgets('по умолчанию: оба «All» активны, чип «Required» виден',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildScreen());
|
await tester.pumpWidget(_buildScreen());
|
||||||
await tester.pump(); // activeUserControllerProvider разрешается
|
await tester.pump(); // activeUserControllerProvider разрешается
|
||||||
@@ -92,10 +92,10 @@ void main() {
|
|||||||
// «All» в обеих строках фильтров.
|
// «All» в обеих строках фильтров.
|
||||||
expect(find.text('All'), findsNWidgets(2));
|
expect(find.text('All'), findsNWidgets(2));
|
||||||
// Чип обязательности + пилюля tx1 в списке.
|
// Чип обязательности + пилюля tx1 в списке.
|
||||||
expect(find.text('Necessary'), findsNWidgets(2));
|
expect(find.text('Required'), findsNWidgets(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('выбор «Impulse» скрывает чип «Necessary»', (tester) async {
|
testWidgets('выбор «Impulse» скрывает чип «Required»', (tester) async {
|
||||||
await tester.pumpWidget(_buildScreen());
|
await tester.pumpWidget(_buildScreen());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@@ -103,21 +103,21 @@ void main() {
|
|||||||
// AnimatedSize — доигрываем анимацию.
|
// AnimatedSize — доигрываем анимацию.
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Чип скрыт, tx1 отфильтрована — «Necessary» нет нигде.
|
// Чип скрыт, tx1 отфильтрована — «Required» нет нигде.
|
||||||
expect(find.text('Necessary'), findsNothing);
|
expect(find.text('Required'), findsNothing);
|
||||||
|
|
||||||
// Возврат на «All» импульсивности возвращает чип.
|
// Возврат на «All» импульсивности возвращает чип.
|
||||||
await tester.tap(find.text('All').first);
|
await tester.tap(find.text('All').first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Necessary'), findsNWidgets(2));
|
expect(find.text('Required'), findsNWidgets(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('выбор «Impulse» сбрасывает выбранную «Necessary» (каскад)',
|
testWidgets('выбор «Impulse» сбрасывает выбранную «Required» (каскад)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildScreen());
|
await tester.pumpWidget(_buildScreen());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.text('Necessary').first);
|
await tester.tap(find.text('Required').first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final container = _container(tester);
|
final container = _container(tester);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:new_budget/src/features/analytics/domain/month_math.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('month_math', () {
|
||||||
|
test('monthStart / monthEnd — границы как в month_summary', () {
|
||||||
|
final m = DateTime(2026, 7, 15, 13, 45);
|
||||||
|
expect(monthStart(m), DateTime(2026, 7));
|
||||||
|
expect(monthEnd(m), DateTime(2026, 7, 31, 23, 59, 59, 999));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monthEnd — декабрь переходит в новый год', () {
|
||||||
|
expect(monthEnd(DateTime(2025, 12)), DateTime(2025, 12, 31, 23, 59, 59, 999));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('previousMonth / addMonths — переходы через границу года', () {
|
||||||
|
expect(previousMonth(DateTime(2026, 1)), DateTime(2025, 12));
|
||||||
|
expect(addMonths(DateTime(2026, 11), 3), DateTime(2027, 2));
|
||||||
|
expect(addMonths(DateTime(2026, 2), -14), DateTime(2024, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monthKeyOf ↔ monthFromKey — roundtrip, паддинг нулём', () {
|
||||||
|
expect(monthKeyOf(DateTime(2026, 3)), '2026-03');
|
||||||
|
expect(monthFromKey('2026-03'), DateTime(2026, 3));
|
||||||
|
expect(monthFromKey(monthKeyOf(DateTime(2024, 12))), DateTime(2024, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daysInMonth — включая високосный февраль', () {
|
||||||
|
expect(daysInMonth(DateTime(2026, 2)), 28);
|
||||||
|
expect(daysInMonth(DateTime(2024, 2)), 29);
|
||||||
|
expect(daysInMonth(DateTime(2026, 7)), 31);
|
||||||
|
expect(daysInMonth(DateTime(2026, 6)), 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ void main() {
|
|||||||
impulse: SpendingImpulse.impulsive,
|
impulse: SpendingImpulse.impulsive,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
expect(find.text('Necessary'), findsOneWidget);
|
expect(find.text('Required'), findsOneWidget);
|
||||||
expect(find.text('Impulse'), findsOneWidget);
|
expect(find.text('Impulse'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.bolt), findsOneWidget);
|
expect(find.byIcon(Icons.bolt), findsOneWidget);
|
||||||
expect(find.text('not marked'), findsNothing);
|
expect(find.text('not marked'), findsNothing);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('комментарий'), findsNothing);
|
expect(find.text('комментарий'), findsNothing);
|
||||||
expect(find.text('Necessary'), findsOneWidget);
|
expect(find.text('Required'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('флаг включён, оценок нет → "not marked" вместо extraInfo',
|
testWidgets('флаг включён, оценок нет → "not marked" вместо extraInfo',
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/account_binding.dart';
|
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/source_app.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart';
|
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/repositories/source_apps_repository.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/rule_candidates_repository.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/repositories/rule_candidates_repository.dart';
|
||||||
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
|
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
|
||||||
@@ -94,6 +94,12 @@ class _FakeRawMessagesRepo implements RawMessagesRepository {
|
|||||||
final List<(String, String)> linked = [];
|
final List<(String, String)> linked = [];
|
||||||
final List<(String, RawMessageStatus)> statusUpdates = [];
|
final List<(String, RawMessageStatus)> statusUpdates = [];
|
||||||
final List<Map<String, Object?>> afterParse = [];
|
final List<Map<String, Object?>> afterParse = [];
|
||||||
|
final List<String> retried = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> resetForRetry(String id) async {
|
||||||
|
retried.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> linkTransaction(String id, String transactionId) async {
|
Future<void> linkTransaction(String id, String transactionId) async {
|
||||||
@@ -214,40 +220,23 @@ class _FakeRuleCandidatesRepo implements RuleCandidatesRepository {
|
|||||||
throw UnimplementedError(invocation.memberName.toString());
|
throw UnimplementedError(invocation.memberName.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeAccountBindingsRepo implements AccountBindingsRepository {
|
class _FakeSourceAppsRepo implements SourceAppsRepository {
|
||||||
AccountBinding? existing;
|
/// Приложение-источник сообщения (null — не добавлено в allowlist).
|
||||||
final List<Map<String, Object?>> created = [];
|
SourceApp? app;
|
||||||
|
|
||||||
|
final List<(String, String?)> defaultsSet = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AccountBinding?> findByPackageAndCard(
|
Future<SourceApp?> findByPackageName(
|
||||||
String userId,
|
String userId,
|
||||||
String packageName,
|
String packageName,
|
||||||
String cardLast4,
|
|
||||||
) async =>
|
) async =>
|
||||||
existing;
|
app?.packageName == packageName ? app : null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AccountBinding> create({
|
Future<void> setDefaultAccount(String id, String? accountId) async {
|
||||||
required String userId,
|
defaultsSet.add((id, accountId));
|
||||||
String? packageName,
|
if (app?.id == id) app = app!.copyWith(defaultAccountId: accountId);
|
||||||
String? bankKey,
|
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
required String accountId,
|
|
||||||
}) async {
|
|
||||||
created.add({
|
|
||||||
'packageName': packageName,
|
|
||||||
'cardLast4': cardLast4,
|
|
||||||
'accountId': accountId,
|
|
||||||
});
|
|
||||||
return AccountBinding(
|
|
||||||
id: 'b1',
|
|
||||||
userId: userId,
|
|
||||||
packageName: packageName,
|
|
||||||
cardLast4: cardLast4,
|
|
||||||
accountId: accountId,
|
|
||||||
createdAt: _now,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -255,12 +244,20 @@ class _FakeAccountBindingsRepo implements AccountBindingsRepository {
|
|||||||
throw UnimplementedError(invocation.memberName.toString());
|
throw UnimplementedError(invocation.memberName.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SourceApp _sourceApp({String? defaultAccountId}) => SourceApp(
|
||||||
|
id: 'app1',
|
||||||
|
userId: _userId,
|
||||||
|
packageName: 'ru.sberbankmobile',
|
||||||
|
defaultAccountId: defaultAccountId,
|
||||||
|
createdAt: _now,
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late _FakeTransactionRepo txRepo;
|
late _FakeTransactionRepo txRepo;
|
||||||
late _FakeRawMessagesRepo rawRepo;
|
late _FakeRawMessagesRepo rawRepo;
|
||||||
late _FakeParseRulesRepo rulesRepo;
|
late _FakeParseRulesRepo rulesRepo;
|
||||||
late _FakeRuleCandidatesRepo candidatesRepo;
|
late _FakeRuleCandidatesRepo candidatesRepo;
|
||||||
late _FakeAccountBindingsRepo bindingsRepo;
|
late _FakeSourceAppsRepo sourceAppsRepo;
|
||||||
late ProviderContainer container;
|
late ProviderContainer container;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
@@ -268,14 +265,14 @@ void main() {
|
|||||||
rawRepo = _FakeRawMessagesRepo();
|
rawRepo = _FakeRawMessagesRepo();
|
||||||
rulesRepo = _FakeParseRulesRepo();
|
rulesRepo = _FakeParseRulesRepo();
|
||||||
candidatesRepo = _FakeRuleCandidatesRepo();
|
candidatesRepo = _FakeRuleCandidatesRepo();
|
||||||
bindingsRepo = _FakeAccountBindingsRepo();
|
sourceAppsRepo = _FakeSourceAppsRepo()..app = _sourceApp();
|
||||||
container = ProviderContainer(
|
container = ProviderContainer(
|
||||||
overrides: [
|
overrides: [
|
||||||
transactionRepositoryProvider.overrideWithValue(txRepo),
|
transactionRepositoryProvider.overrideWithValue(txRepo),
|
||||||
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
|
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
|
||||||
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
|
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
|
||||||
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
|
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
|
||||||
accountBindingsRepositoryProvider.overrideWithValue(bindingsRepo),
|
sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -286,8 +283,9 @@ void main() {
|
|||||||
container.read(inboxControllerProvider.notifier);
|
container.read(inboxControllerProvider.notifier);
|
||||||
|
|
||||||
group('createRule', () {
|
group('createRule', () {
|
||||||
test('creates transaction + rule, removes candidate, links, binds', () async {
|
test('creates transaction + rule, removes candidate, links, learns default',
|
||||||
await controller().createRule(
|
() async {
|
||||||
|
final learned = await controller().createRule(
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
message: _message(),
|
message: _message(),
|
||||||
draft: _draft(),
|
draft: _draft(),
|
||||||
@@ -312,41 +310,17 @@ void main() {
|
|||||||
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
|
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
|
||||||
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
||||||
|
|
||||||
// _maybeBind: no existing binding → creates one (card *3456 → acc1).
|
// Авто-обучение: дефолта не было → счёт записан, вернулось true.
|
||||||
expect(bindingsRepo.created, hasLength(1));
|
expect(learned, isTrue);
|
||||||
expect(bindingsRepo.created.single['cardLast4'], '3456');
|
expect(sourceAppsRepo.defaultsSet, [('app1', 'acc1')]);
|
||||||
expect(bindingsRepo.created.single['accountId'], 'acc1');
|
|
||||||
|
|
||||||
expect(container.read(inboxControllerProvider).hasValue, isTrue);
|
expect(container.read(inboxControllerProvider).hasValue, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not re-create binding when one already exists', () async {
|
|
||||||
bindingsRepo.existing = AccountBinding(
|
|
||||||
id: 'old',
|
|
||||||
userId: _userId,
|
|
||||||
packageName: 'ru.sberbankmobile',
|
|
||||||
cardLast4: '3456',
|
|
||||||
accountId: 'acc1',
|
|
||||||
createdAt: _now,
|
|
||||||
);
|
|
||||||
|
|
||||||
await controller().createRule(
|
|
||||||
userId: _userId,
|
|
||||||
message: _message(),
|
|
||||||
draft: _draft(),
|
|
||||||
accountId: 'acc1',
|
|
||||||
categoryId: 'cat1',
|
|
||||||
merchantCanonical: 'PYATEROCHKA',
|
|
||||||
pattern: 'PYATEROCHKA',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(bindingsRepo.created, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('confirmOnce', () {
|
group('confirmOnce', () {
|
||||||
test('creates transaction without a rule, observes candidate', () async {
|
test('creates transaction without a rule, observes candidate', () async {
|
||||||
await controller().confirmOnce(
|
final learned = await controller().confirmOnce(
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
message: _message(),
|
message: _message(),
|
||||||
draft: _draft(),
|
draft: _draft(),
|
||||||
@@ -359,6 +333,50 @@ void main() {
|
|||||||
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
||||||
expect(candidatesRepo.observed, hasLength(1));
|
expect(candidatesRepo.observed, hasLength(1));
|
||||||
expect(candidatesRepo.observed.single['resolvedValue'], 'cat1');
|
expect(candidatesRepo.observed.single['resolvedValue'], 'cat1');
|
||||||
|
expect(learned, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('авто-обучение дефолтного счёта приложения', () {
|
||||||
|
test('дефолт уже задан → не перезаписывается, false', () async {
|
||||||
|
sourceAppsRepo.app = _sourceApp(defaultAccountId: 'acc-old');
|
||||||
|
|
||||||
|
final learned = await controller().confirmOnce(
|
||||||
|
userId: _userId,
|
||||||
|
message: _message(),
|
||||||
|
draft: _draft(),
|
||||||
|
accountId: 'acc-new',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(learned, isFalse);
|
||||||
|
expect(sourceAppsRepo.defaultsSet, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('learnAppDefault: false → пропуск обучения', () async {
|
||||||
|
final learned = await controller().confirmOnce(
|
||||||
|
userId: _userId,
|
||||||
|
message: _message(),
|
||||||
|
draft: _draft(),
|
||||||
|
accountId: 'acc1',
|
||||||
|
learnAppDefault: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(learned, isFalse);
|
||||||
|
expect(sourceAppsRepo.defaultsSet, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('приложение не в allowlist → пропуск обучения', () async {
|
||||||
|
sourceAppsRepo.app = null;
|
||||||
|
|
||||||
|
final learned = await controller().confirmOnce(
|
||||||
|
userId: _userId,
|
||||||
|
message: _message(),
|
||||||
|
draft: _draft(),
|
||||||
|
accountId: 'acc1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(learned, isFalse);
|
||||||
|
expect(sourceAppsRepo.defaultsSet, isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,4 +400,20 @@ void main() {
|
|||||||
expect(rulesRepo.created, isEmpty);
|
expect(rulesRepo.created, isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('retry', () {
|
||||||
|
test('pendingAi message → resetForRetry (сброс попыток и в очередь)',
|
||||||
|
() async {
|
||||||
|
final stuck = _message().copyWith(
|
||||||
|
status: RawMessageStatus.pendingAi,
|
||||||
|
parseAttemptCount: 3,
|
||||||
|
lastParseError: 'network',
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller().retry(stuck);
|
||||||
|
|
||||||
|
expect(rawRepo.retried, ['msg1']);
|
||||||
|
expect(txRepo.created, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:new_budget/src/core/database/app_database.dart';
|
||||||
|
import 'package:new_budget/src/core/providers/database_provider.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
||||||
|
|
||||||
|
/// Тесты авторетрая `pending_ai` в [ParsingWorker] (§7): таймер с бэкоффом
|
||||||
|
/// прогоняет застрявшие сообщения через pipeline, попытки НЕ сбрасываются,
|
||||||
|
/// кэп pipeline (5) переводит хронические сбои в failed. Форма релизного
|
||||||
|
/// бага: connectivity считает сеть живой, а запросы фактически падают.
|
||||||
|
|
||||||
|
const _userId = 'u1';
|
||||||
|
const _bank = 'ru.sberbankmobile';
|
||||||
|
|
||||||
|
/// Быстрый бэкофф, чтобы тест укладывался в сотни миллисекунд.
|
||||||
|
const _testBackoff = (
|
||||||
|
initial: Duration(milliseconds: 20),
|
||||||
|
max: Duration(milliseconds: 50),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// AI-парсер, чей HTTP-клиент падает, пока [failing] возвращает true,
|
||||||
|
/// и отвечает валидным draft-JSON после.
|
||||||
|
AiParser _flakyAiParser({
|
||||||
|
required bool Function() failing,
|
||||||
|
void Function()? onCall,
|
||||||
|
}) {
|
||||||
|
final mock = MockClient((req) async {
|
||||||
|
onCall?.call();
|
||||||
|
if (failing()) throw http.ClientException('conn refused');
|
||||||
|
final content = jsonEncode({
|
||||||
|
'type': 'expense',
|
||||||
|
'kind': 'purchase',
|
||||||
|
'amount': 1500,
|
||||||
|
'currency': 'RUB',
|
||||||
|
'merchantRaw': 'LENTA',
|
||||||
|
});
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': {'content': content},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'usage': {'total_tokens': 42},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _seed(AppDatabase db) async {
|
||||||
|
await db.usersDao.insertUser(
|
||||||
|
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||||
|
);
|
||||||
|
// Банк в allowlist, чтобы pipeline доходил до AI.
|
||||||
|
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||||
|
id: 'src-1',
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _bank,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enableAi(ProviderContainer c) async {
|
||||||
|
final settings = c.read(parsingSettingsControllerProvider.notifier);
|
||||||
|
await c.read(parsingSettingsControllerProvider.future);
|
||||||
|
await settings.setAiConsent(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RawMessage> _insert(RawMessagesRepository repo) => repo.insertIncoming(
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _bank,
|
||||||
|
body: 'Payment of 1500 RUB at LENTA',
|
||||||
|
receivedAt: DateTime(2026, 5, 31, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Ждёт, пока сообщение [id] не удовлетворит [predicate].
|
||||||
|
Future<RawMessage> _waitFor(
|
||||||
|
RawMessagesRepository repo,
|
||||||
|
String id,
|
||||||
|
bool Function(RawMessage) predicate, {
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
final completer = Completer<RawMessage>();
|
||||||
|
late final StreamSubscription<List<RawMessage>> sub;
|
||||||
|
sub = repo.watchAll(_userId).listen((list) {
|
||||||
|
for (final m in list) {
|
||||||
|
if (m.id == id && predicate(m)) {
|
||||||
|
if (!completer.isCompleted) completer.complete(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await completer.future.timeout(timeout);
|
||||||
|
} finally {
|
||||||
|
await sub.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RawMessage> _current(RawMessagesRepository repo, String id) async {
|
||||||
|
final list = await repo.watchAll(_userId).first;
|
||||||
|
return list.firstWhere((m) => m.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Активный слушатель воркера — эквивалент `ref.watch` в AppScaffold.
|
||||||
|
/// `container.read` недостаточно: у паузнутого воркера внутренние `ref.listen`
|
||||||
|
/// (isOnline) не активируют autoDispose-зависимости (pause-семантика
|
||||||
|
/// Riverpod 3), и онлайн-статус навсегда остаётся null «как бы онлайн».
|
||||||
|
void _activateWorker(ProviderContainer c) {
|
||||||
|
c.listen(parsingWorkerProvider(_userId), (_, _) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late ProviderContainer container;
|
||||||
|
late RawMessagesRepository repo;
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
container.dispose();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'сеть «живая», но запросы падают → таймер ретраит без сброса попыток '
|
||||||
|
'→ после 5 попыток failed «AI retry limit reached»', () async {
|
||||||
|
var aiCalls = 0;
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
await _seed(db);
|
||||||
|
container = ProviderContainer(overrides: [
|
||||||
|
appDatabaseProvider.overrideWithValue(db),
|
||||||
|
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||||
|
aiRetryBackoffProvider.overrideWithValue(_testBackoff),
|
||||||
|
aiParserProvider.overrideWith((ref) async => _flakyAiParser(
|
||||||
|
failing: () => true,
|
||||||
|
onCall: () => aiCalls++,
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
repo = container.read(rawMessagesRepositoryProvider);
|
||||||
|
await _enableAi(container);
|
||||||
|
_activateWorker(container);
|
||||||
|
|
||||||
|
final inserted = await _insert(repo);
|
||||||
|
|
||||||
|
final msg = await _waitFor(
|
||||||
|
repo,
|
||||||
|
inserted.id,
|
||||||
|
(m) => m.status == RawMessageStatus.failed,
|
||||||
|
);
|
||||||
|
expect(msg.lastParseError, 'AI retry limit reached');
|
||||||
|
expect(msg.parseAttemptCount, 5,
|
||||||
|
reason: 'авторетрай не должен сбрасывать счётчик попыток');
|
||||||
|
expect(aiCalls, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('реальный офлайн → pending_ai без расхода попыток, таймер не ретраит',
|
||||||
|
() async {
|
||||||
|
var aiCalls = 0;
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
await _seed(db);
|
||||||
|
container = ProviderContainer(overrides: [
|
||||||
|
appDatabaseProvider.overrideWithValue(db),
|
||||||
|
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(false)),
|
||||||
|
aiRetryBackoffProvider.overrideWithValue(_testBackoff),
|
||||||
|
aiParserProvider.overrideWith((ref) async => _flakyAiParser(
|
||||||
|
failing: () => true,
|
||||||
|
onCall: () => aiCalls++,
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
repo = container.read(rawMessagesRepositoryProvider);
|
||||||
|
await _enableAi(container);
|
||||||
|
_activateWorker(container);
|
||||||
|
// Даём эмиссии `false` дойти до isOnlineProvider до вставки сообщения.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final inserted = await _insert(repo);
|
||||||
|
|
||||||
|
await _waitFor(
|
||||||
|
repo,
|
||||||
|
inserted.id,
|
||||||
|
(m) => m.status == RawMessageStatus.pendingAi,
|
||||||
|
);
|
||||||
|
// Несколько периодов бэкоффа: таймер обязан скипать на явном офлайне.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
final msg = await _current(repo, inserted.id);
|
||||||
|
expect(msg.status, RawMessageStatus.pendingAi);
|
||||||
|
expect(msg.lastParseError, 'offline');
|
||||||
|
expect(msg.parseAttemptCount, 0);
|
||||||
|
expect(aiCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('восстановление сети (edge-триггер) → застрявшее сообщение доходит до inbox',
|
||||||
|
() async {
|
||||||
|
var failing = true;
|
||||||
|
final online = StreamController<bool>();
|
||||||
|
addTearDown(online.close);
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
await _seed(db);
|
||||||
|
container = ProviderContainer(overrides: [
|
||||||
|
appDatabaseProvider.overrideWithValue(db),
|
||||||
|
isOnlineProvider.overrideWith((ref) => online.stream),
|
||||||
|
aiRetryBackoffProvider.overrideWithValue(_testBackoff),
|
||||||
|
aiParserProvider.overrideWith(
|
||||||
|
(ref) async => _flakyAiParser(failing: () => failing)),
|
||||||
|
]);
|
||||||
|
repo = container.read(rawMessagesRepositoryProvider);
|
||||||
|
await _enableAi(container);
|
||||||
|
_activateWorker(container);
|
||||||
|
|
||||||
|
online.add(false);
|
||||||
|
// Даём эмиссии дойти до isOnlineProvider до вставки сообщения.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final inserted = await _insert(repo);
|
||||||
|
await _waitFor(
|
||||||
|
repo,
|
||||||
|
inserted.id,
|
||||||
|
(m) => m.status == RawMessageStatus.pendingAi,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сеть вернулась, запросы снова проходят.
|
||||||
|
failing = false;
|
||||||
|
online.add(true);
|
||||||
|
|
||||||
|
final msg = await _waitFor(
|
||||||
|
repo,
|
||||||
|
inserted.id,
|
||||||
|
(m) => m.status == RawMessageStatus.inbox,
|
||||||
|
);
|
||||||
|
// Правила для мерчанта нет → gate не пропускает, сообщение ждёт в Inbox.
|
||||||
|
expect(msg.status, RawMessageStatus.inbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('фича выключена → таймер не трогает pending_ai', () async {
|
||||||
|
var aiCalls = 0;
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
await _seed(db);
|
||||||
|
container = ProviderContainer(overrides: [
|
||||||
|
appDatabaseProvider.overrideWithValue(db),
|
||||||
|
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||||
|
aiRetryBackoffProvider.overrideWithValue(_testBackoff),
|
||||||
|
aiParserProvider.overrideWith((ref) async => _flakyAiParser(
|
||||||
|
failing: () => true,
|
||||||
|
onCall: () => aiCalls++,
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
repo = container.read(rawMessagesRepositoryProvider);
|
||||||
|
await _enableAi(container);
|
||||||
|
await container
|
||||||
|
.read(parsingSettingsControllerProvider.notifier)
|
||||||
|
.setEnabled(false);
|
||||||
|
|
||||||
|
// Паркуем сообщение в pending_ai ДО активации воркера (при выключенной
|
||||||
|
// фиче pipeline не обрабатывает pending, поэтому статус ставим напрямую).
|
||||||
|
final inserted = await _insert(repo);
|
||||||
|
await repo.updateStatus(inserted.id, RawMessageStatus.pendingAi);
|
||||||
|
|
||||||
|
_activateWorker(container);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
final msg = await _current(repo, inserted.id);
|
||||||
|
expect(msg.status, RawMessageStatus.pendingAi);
|
||||||
|
expect(msg.parseAttemptCount, 0);
|
||||||
|
expect(aiCalls, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -198,8 +198,8 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'включённый банк без карты + глобальный дефолт + правило категории '
|
'включённый банк + дефолтный счёт приложения + правило категории '
|
||||||
'→ авто-применение на дефолтный счёт', () async {
|
'→ авто-применение на счёт приложения', () async {
|
||||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
await _seed(db);
|
await _seed(db);
|
||||||
container = ProviderContainer(overrides: [
|
container = ProviderContainer(overrides: [
|
||||||
@@ -211,16 +211,15 @@ void main() {
|
|||||||
repo = container.read(rawMessagesRepositoryProvider);
|
repo = container.read(rawMessagesRepositoryProvider);
|
||||||
await enableAi(container);
|
await enableAi(container);
|
||||||
|
|
||||||
// Allowlist: банк включён.
|
// Allowlist: банк включён, дефолтный счёт приложения задан (trusted).
|
||||||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||||
id: 'src-1',
|
id: 'src-1',
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
packageName: _bank,
|
packageName: _bank,
|
||||||
));
|
));
|
||||||
// Глобальный дефолтный счёт.
|
|
||||||
await container
|
await container
|
||||||
.read(accountRepositoryProvider)
|
.read(sourceAppsRepositoryProvider)
|
||||||
.setDefault(_accountId, _userId);
|
.setDefaultAccount('src-1', _accountId);
|
||||||
// Категория + правило merchant→category на «LENTA».
|
// Категория + правило merchant→category на «LENTA».
|
||||||
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
|
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
|
||||||
id: 'cat-1',
|
id: 'cat-1',
|
||||||
@@ -237,7 +236,7 @@ void main() {
|
|||||||
categoryId: 'cat-1',
|
categoryId: 'cat-1',
|
||||||
);
|
);
|
||||||
// Gate-чек-лист: правило есть, сумма 1500 находится в теле, валюта RUB,
|
// Gate-чек-лист: правило есть, сумма 1500 находится в теле, валюта RUB,
|
||||||
// тип совпадает с правилом, счёт — глобальный дефолт (trusted).
|
// тип совпадает с правилом, счёт — дефолт приложения (trusted).
|
||||||
|
|
||||||
_activateWorker(container);
|
_activateWorker(container);
|
||||||
|
|
||||||
@@ -252,13 +251,66 @@ void main() {
|
|||||||
expect(msg.status, RawMessageStatus.applied,
|
expect(msg.status, RawMessageStatus.applied,
|
||||||
reason: 'lastParseError=${msg.lastParseError}');
|
reason: 'lastParseError=${msg.lastParseError}');
|
||||||
|
|
||||||
// Создалась транзакция на дефолтном счёте.
|
// Создалась транзакция на дефолтном счёте приложения.
|
||||||
final txns = await db.select(db.transactionsTable).get();
|
final txns = await db.select(db.transactionsTable).get();
|
||||||
expect(txns, hasLength(1));
|
expect(txns, hasLength(1));
|
||||||
expect(txns.first.accountId, _accountId);
|
expect(txns.first.accountId, _accountId);
|
||||||
expect(txns.first.categoryId, 'cat-1');
|
expect(txns.first.categoryId, 'cat-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'только глобальный дефолт (без дефолта приложения) → Inbox с префиллом, '
|
||||||
|
'НЕ авто-применение (смена поведения)', () async {
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
await _seed(db);
|
||||||
|
container = ProviderContainer(overrides: [
|
||||||
|
appDatabaseProvider.overrideWithValue(db),
|
||||||
|
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||||
|
aiParserProvider.overrideWith((ref) async =>
|
||||||
|
_fakeAiParser(merchantRaw: 'LENTA', amount: 1500)),
|
||||||
|
]);
|
||||||
|
repo = container.read(rawMessagesRepositoryProvider);
|
||||||
|
await enableAi(container);
|
||||||
|
|
||||||
|
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||||
|
id: 'src-1',
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _bank,
|
||||||
|
));
|
||||||
|
// Глобальный дефолтный счёт есть, дефолт приложения — нет.
|
||||||
|
await container
|
||||||
|
.read(accountRepositoryProvider)
|
||||||
|
.setDefault(_accountId, _userId);
|
||||||
|
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
|
||||||
|
id: 'cat-1',
|
||||||
|
userId: _userId,
|
||||||
|
name: 'Продукты',
|
||||||
|
));
|
||||||
|
await container.read(parseRulesRepositoryProvider).create(
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _bank,
|
||||||
|
kind: ParseRuleKind.merchantToCategory,
|
||||||
|
matchMode: MatchMode.contains,
|
||||||
|
pattern: 'LENTA',
|
||||||
|
txType: TransactionType.expense,
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
_activateWorker(container);
|
||||||
|
|
||||||
|
final inserted = await repo.insertIncoming(
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _bank,
|
||||||
|
body: 'Payment of 1500 RUB at LENTA',
|
||||||
|
receivedAt: DateTime(2026, 5, 31, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
final msg = await _waitTerminal(repo, inserted.id);
|
||||||
|
expect(msg.status, RawMessageStatus.inbox,
|
||||||
|
reason: 'глобальный дефолт больше не trusted — нужен первый Confirm');
|
||||||
|
expect(await db.select(db.transactionsTable).get(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('allowlist обновляется без перезапуска: добавили банк → парсится',
|
test('allowlist обновляется без перезапуска: добавили банк → парсится',
|
||||||
() async {
|
() async {
|
||||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
|||||||
@@ -141,12 +141,11 @@ void main() {
|
|||||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||||
await container.read(parsingSettingsControllerProvider.future);
|
await container.read(parsingSettingsControllerProvider.future);
|
||||||
await settings.setAiConsent(true);
|
await settings.setAiConsent(true);
|
||||||
// Однозначные привязки: bankA → accA, bankB → accB (resolver #3, trusted).
|
// Дефолтные счета приложений: bankA → accA, bankB → accB (resolver #2,
|
||||||
final bindings = container.read(accountBindingsRepositoryProvider);
|
// trusted).
|
||||||
await bindings.create(
|
final sourceApps = container.read(sourceAppsRepositoryProvider);
|
||||||
userId: _userId, packageName: _bankA, accountId: _accA);
|
await sourceApps.setDefaultAccount('src-a', _accA);
|
||||||
await bindings.create(
|
await sourceApps.setDefaultAccount('src-b', _accB);
|
||||||
userId: _userId, packageName: _bankB, accountId: _accB);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:new_budget/src/core/database/app_database.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||||
|
|
||||||
|
/// Видимость статусов в Inbox: `watchInbox` и `watchInboxCount` должны
|
||||||
|
/// показывать одинаковый набор статусов (в count — сырая SQL-строка, её легко
|
||||||
|
/// рассинхронизировать с запросом списка). Отдельно охраняем `pendingAi`:
|
||||||
|
/// застрявшие «ждёт сети» сообщения обязаны быть видны пользователю.
|
||||||
|
|
||||||
|
const _userId = 'u1';
|
||||||
|
const _bank = 'ru.sberbankmobile';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late RawMessagesRepositoryImpl repo;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
await db.usersDao.insertUser(
|
||||||
|
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||||
|
);
|
||||||
|
repo = RawMessagesRepositoryImpl(RawMessagesDao(db));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
Future<String> insertWithStatus(RawMessageStatus status, DateTime t) async {
|
||||||
|
final m = await repo.insertIncoming(
|
||||||
|
userId: _userId,
|
||||||
|
packageName: _bank,
|
||||||
|
body: 'Payment at $t',
|
||||||
|
receivedAt: t,
|
||||||
|
);
|
||||||
|
await repo.updateStatus(m.id, status);
|
||||||
|
return m.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pendingAi виден и в watchInbox, и в watchInboxCount', () async {
|
||||||
|
final id = await insertWithStatus(
|
||||||
|
RawMessageStatus.pendingAi,
|
||||||
|
DateTime(2026, 6, 10, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
final inbox = await repo.watchInbox(_userId).first;
|
||||||
|
expect(inbox.map((m) => m.id), contains(id));
|
||||||
|
expect(await repo.watchInboxCount(_userId).first, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchInbox и watchInboxCount согласованы по всем статусам', () async {
|
||||||
|
var minute = 0;
|
||||||
|
for (final status in RawMessageStatus.values) {
|
||||||
|
await insertWithStatus(status, DateTime(2026, 6, 10, 12, minute++));
|
||||||
|
}
|
||||||
|
|
||||||
|
final inbox = await repo.watchInbox(_userId).first;
|
||||||
|
final count = await repo.watchInboxCount(_userId).first;
|
||||||
|
expect(count, inbox.length,
|
||||||
|
reason: 'сырая SQL-строка count разошлась с запросом watchInbox');
|
||||||
|
expect(
|
||||||
|
inbox.map((m) => m.status).toSet(),
|
||||||
|
{
|
||||||
|
RawMessageStatus.inbox,
|
||||||
|
RawMessageStatus.parsedPartial,
|
||||||
|
RawMessageStatus.failed,
|
||||||
|
RawMessageStatus.pendingAi,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,136 +1,43 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart';
|
import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/account_binding.dart';
|
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart';
|
|
||||||
|
|
||||||
const _userId = 'u1';
|
const _userId = 'u1';
|
||||||
const _pkg = 'ru.sberbankmobile';
|
|
||||||
final _now = DateTime(2026, 1, 1);
|
final _now = DateTime(2026, 1, 1);
|
||||||
|
|
||||||
AccountBinding _binding({
|
ParseRule _senderRule(String pattern, String accountId, {bool enabled = true}) =>
|
||||||
required String id,
|
ParseRule(
|
||||||
required String accountId,
|
id: 'sr-$pattern',
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
bool isDefault = false,
|
|
||||||
}) =>
|
|
||||||
AccountBinding(
|
|
||||||
id: id,
|
|
||||||
userId: _userId,
|
|
||||||
packageName: _pkg,
|
|
||||||
cardLast4: cardLast4,
|
|
||||||
phone: phone,
|
|
||||||
accountId: accountId,
|
|
||||||
isDefault: isDefault,
|
|
||||||
createdAt: _now,
|
|
||||||
);
|
|
||||||
|
|
||||||
ParseRule _senderRule(String pattern, String accountId) => ParseRule(
|
|
||||||
id: 'sr',
|
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
kind: ParseRuleKind.senderToAccount,
|
kind: ParseRuleKind.senderToAccount,
|
||||||
matchMode: MatchMode.contains,
|
matchMode: MatchMode.contains,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
|
enabled: enabled,
|
||||||
createdAt: _now,
|
createdAt: _now,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Конфигурируемый фейк: возвращает заданные привязки из in-memory списка.
|
AccountResolution _resolve({
|
||||||
class _FakeBindingsRepo implements AccountBindingsRepository {
|
|
||||||
_FakeBindingsRepo(this.bindings);
|
|
||||||
final List<AccountBinding> bindings;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findByPackageAndCard(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
String cardLast4,
|
|
||||||
) async =>
|
|
||||||
bindings
|
|
||||||
.where((b) =>
|
|
||||||
b.packageName == packageName && b.cardLast4 == cardLast4)
|
|
||||||
.firstOrNull;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findByPhone(String userId, String phone) async =>
|
|
||||||
bindings.where((b) => b.phone == phone).firstOrNull;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<AccountBinding>> findByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) async =>
|
|
||||||
bindings.where((b) => b.packageName == packageName).toList();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AccountBinding?> findDefaultByPackageName(
|
|
||||||
String userId,
|
|
||||||
String packageName,
|
|
||||||
) async =>
|
|
||||||
bindings
|
|
||||||
.where((b) => b.packageName == packageName && b.isDefault)
|
|
||||||
.firstOrNull;
|
|
||||||
|
|
||||||
@override
|
|
||||||
dynamic noSuchMethod(Invocation invocation) =>
|
|
||||||
throw UnimplementedError(invocation.memberName.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AccountResolution> _resolve(
|
|
||||||
List<AccountBinding> bindings, {
|
|
||||||
String? cardLast4,
|
|
||||||
String? phone,
|
|
||||||
List<ParseRule> senderRules = const [],
|
List<ParseRule> senderRules = const [],
|
||||||
|
String? appDefaultAccountId,
|
||||||
String? globalDefaultAccountId,
|
String? globalDefaultAccountId,
|
||||||
String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
|
String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
|
||||||
}) {
|
}) =>
|
||||||
final resolver = AccountResolver(_FakeBindingsRepo(bindings));
|
resolveAccount(
|
||||||
return resolver.resolve(
|
body: body,
|
||||||
userId: _userId,
|
senderRules: senderRules,
|
||||||
packageName: _pkg,
|
appDefaultAccountId: appDefaultAccountId,
|
||||||
body: body,
|
globalDefaultAccountId: globalDefaultAccountId,
|
||||||
cardLast4: cardLast4,
|
);
|
||||||
phone: phone,
|
|
||||||
senderRules: senderRules,
|
|
||||||
globalDefaultAccountId: globalDefaultAccountId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AccountResolver.resolve (table B)', () {
|
group('resolveAccount (лестница §B)', () {
|
||||||
test('#1 binding by package + card → 100, trusted', () async {
|
test('#1 senderToAccount rule → 90, trusted; бьёт оба дефолта', () {
|
||||||
final r = await _resolve(
|
final r = _resolve(
|
||||||
[_binding(id: 'b', accountId: 'acc-card', cardLast4: '3456')],
|
senderRules: [_senderRule('*3456', 'acc-rule')],
|
||||||
cardLast4: '3456',
|
appDefaultAccountId: 'acc-app',
|
||||||
);
|
globalDefaultAccountId: 'acc-global',
|
||||||
expect(r.accountId, 'acc-card');
|
|
||||||
expect(r.score, 100);
|
|
||||||
expect(r.trusted, isTrue);
|
|
||||||
expect(r.source, AccountSource.bindingCard);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#1 binding by phone → 100, trusted', () async {
|
|
||||||
final r = await _resolve(
|
|
||||||
[_binding(id: 'b', accountId: 'acc-phone', phone: '+79990001122')],
|
|
||||||
phone: '+79990001122',
|
|
||||||
);
|
|
||||||
expect(r.accountId, 'acc-phone');
|
|
||||||
expect(r.score, 100);
|
|
||||||
expect(r.trusted, isTrue);
|
|
||||||
expect(r.source, AccountSource.bindingPhone);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#2 senderToAccount rule → 90, trusted', () async {
|
|
||||||
// Несколько привязок без default, чтобы single/default не перехватили,
|
|
||||||
// но правило отправителя имеет приоритет над ними.
|
|
||||||
final r = await _resolve(
|
|
||||||
[
|
|
||||||
_binding(id: 'b1', accountId: 'a1'),
|
|
||||||
_binding(id: 'b2', accountId: 'a2'),
|
|
||||||
],
|
|
||||||
senderRules: [_senderRule('PYATEROCHKA', 'acc-rule')],
|
|
||||||
);
|
);
|
||||||
expect(r.accountId, 'acc-rule');
|
expect(r.accountId, 'acc-rule');
|
||||||
expect(r.score, 90);
|
expect(r.score, 90);
|
||||||
@@ -138,46 +45,39 @@ void main() {
|
|||||||
expect(r.source, AccountSource.senderRule);
|
expect(r.source, AccountSource.senderRule);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#3 single binding by package → 75, trusted', () async {
|
test('#1 несовпавшее/выключенное правило пропускается', () {
|
||||||
final r = await _resolve([_binding(id: 'b', accountId: 'acc-single')]);
|
final r = _resolve(
|
||||||
expect(r.accountId, 'acc-single');
|
senderRules: [
|
||||||
expect(r.score, 75);
|
_senderRule('НЕ СОВПАДЁТ', 'acc-miss'),
|
||||||
expect(r.trusted, isTrue);
|
_senderRule('*3456', 'acc-off', enabled: false),
|
||||||
expect(r.source, AccountSource.singleBinding);
|
],
|
||||||
|
appDefaultAccountId: 'acc-app',
|
||||||
|
);
|
||||||
|
expect(r.accountId, 'acc-app');
|
||||||
|
expect(r.source, AccountSource.appDefault);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#4 per-app default binding → 70, trusted', () async {
|
test('#2 дефолт приложения → 75, trusted', () {
|
||||||
final r = await _resolve([
|
final r = _resolve(
|
||||||
_binding(id: 'b1', accountId: 'a1'),
|
appDefaultAccountId: 'acc-app',
|
||||||
_binding(id: 'b2', accountId: 'acc-default', isDefault: true),
|
globalDefaultAccountId: 'acc-global',
|
||||||
]);
|
);
|
||||||
expect(r.accountId, 'acc-default');
|
expect(r.accountId, 'acc-app');
|
||||||
expect(r.score, 70);
|
expect(r.score, 75);
|
||||||
expect(r.trusted, isTrue);
|
expect(r.trusted, isTrue);
|
||||||
expect(r.source, AccountSource.appDefault);
|
expect(r.source, AccountSource.appDefault);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#5 multiple bindings, no default → 45, NOT trusted', () async {
|
test('#3 глобальный дефолт → 40, NOT trusted (смена поведения)', () {
|
||||||
final r = await _resolve([
|
final r = _resolve(globalDefaultAccountId: 'acc-global');
|
||||||
_binding(id: 'b1', accountId: 'a1'),
|
|
||||||
_binding(id: 'b2', accountId: 'a2'),
|
|
||||||
]);
|
|
||||||
expect(r.accountId, 'a1');
|
|
||||||
expect(r.score, 45);
|
|
||||||
expect(r.trusted, isFalse);
|
|
||||||
expect(r.source, AccountSource.ambiguous);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#6 global default account → 40, trusted', () async {
|
|
||||||
final r = await _resolve(const [], globalDefaultAccountId: 'acc-global');
|
|
||||||
expect(r.accountId, 'acc-global');
|
expect(r.accountId, 'acc-global');
|
||||||
expect(r.score, 40);
|
expect(r.score, 40);
|
||||||
expect(r.trusted, isTrue);
|
expect(r.trusted, isFalse);
|
||||||
expect(r.source, AccountSource.globalDefault);
|
expect(r.source, AccountSource.globalDefault);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#7 nothing → null, 15, NOT trusted', () async {
|
test('#4 ничего → null, 15, NOT trusted', () {
|
||||||
final r = await _resolve(const []);
|
final r = _resolve();
|
||||||
expect(r.accountId, isNull);
|
expect(r.accountId, isNull);
|
||||||
expect(r.score, 15);
|
expect(r.score, 15);
|
||||||
expect(r.trusted, isFalse);
|
expect(r.trusted, isFalse);
|
||||||
|
|||||||
@@ -49,16 +49,16 @@ ParseDraft _draft({
|
|||||||
|
|
||||||
const _trusted = AccountResolution(
|
const _trusted = AccountResolution(
|
||||||
accountId: 'a1',
|
accountId: 'a1',
|
||||||
score: 100,
|
score: 90,
|
||||||
trusted: true,
|
trusted: true,
|
||||||
source: AccountSource.bindingCard,
|
source: AccountSource.senderRule,
|
||||||
);
|
);
|
||||||
|
|
||||||
const _untrusted = AccountResolution(
|
const _untrusted = AccountResolution(
|
||||||
accountId: 'a1',
|
accountId: 'a1',
|
||||||
score: 45,
|
score: 40,
|
||||||
trusted: false,
|
trusted: false,
|
||||||
source: AccountSource.ambiguous,
|
source: AccountSource.globalDefault,
|
||||||
);
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -166,7 +166,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('untrusted account (ambiguous multi-binding) → inbox', () {
|
test('untrusted account (global default) → inbox', () {
|
||||||
final r = decide(
|
final r = decide(
|
||||||
autoApplyEnabled: true,
|
autoApplyEnabled: true,
|
||||||
merchantRule: _rule(),
|
merchantRule: _rule(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
|||||||
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
|
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
|
||||||
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
|
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
|
||||||
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
|
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
|
||||||
|
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
||||||
@@ -25,6 +26,7 @@ class FakeInboxController extends InboxController {
|
|||||||
int createRuleCalls = 0;
|
int createRuleCalls = 0;
|
||||||
int confirmOnceCalls = 0;
|
int confirmOnceCalls = 0;
|
||||||
int ignoreCalls = 0;
|
int ignoreCalls = 0;
|
||||||
|
int retryCalls = 0;
|
||||||
String? lastCreateRuleCategory;
|
String? lastCreateRuleCategory;
|
||||||
String? lastCreateRuleAccount;
|
String? lastCreateRuleAccount;
|
||||||
String? lastConfirmCategory;
|
String? lastConfirmCategory;
|
||||||
@@ -34,8 +36,12 @@ class FakeInboxController extends InboxController {
|
|||||||
@override
|
@override
|
||||||
AsyncValue<void> build() => const AsyncData(null);
|
AsyncValue<void> build() => const AsyncData(null);
|
||||||
|
|
||||||
|
/// Что возвращать из createRule/confirmOnce: true = «дефолт приложения
|
||||||
|
/// только что выучен» → карточка показывает SnackBar.
|
||||||
|
bool learnResult = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createRule({
|
Future<bool> createRule({
|
||||||
required String userId,
|
required String userId,
|
||||||
required RawMessage message,
|
required RawMessage message,
|
||||||
required ParseDraft draft,
|
required ParseDraft draft,
|
||||||
@@ -44,25 +50,27 @@ class FakeInboxController extends InboxController {
|
|||||||
required String merchantCanonical,
|
required String merchantCanonical,
|
||||||
required String pattern,
|
required String pattern,
|
||||||
MatchMode matchMode = MatchMode.contains,
|
MatchMode matchMode = MatchMode.contains,
|
||||||
bool bindAccount = true,
|
bool learnAppDefault = true,
|
||||||
}) async {
|
}) async {
|
||||||
createRuleCalls++;
|
createRuleCalls++;
|
||||||
lastCreateRuleCategory = categoryId;
|
lastCreateRuleCategory = categoryId;
|
||||||
lastCreateRuleAccount = accountId;
|
lastCreateRuleAccount = accountId;
|
||||||
|
return learnResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> confirmOnce({
|
Future<bool> confirmOnce({
|
||||||
required String userId,
|
required String userId,
|
||||||
required RawMessage message,
|
required RawMessage message,
|
||||||
required ParseDraft draft,
|
required ParseDraft draft,
|
||||||
required String accountId,
|
required String accountId,
|
||||||
String? categoryId,
|
String? categoryId,
|
||||||
bool bindAccount = true,
|
bool learnAppDefault = true,
|
||||||
}) async {
|
}) async {
|
||||||
confirmOnceCalls++;
|
confirmOnceCalls++;
|
||||||
lastConfirmCategory = categoryId;
|
lastConfirmCategory = categoryId;
|
||||||
lastConfirmAccount = accountId;
|
lastConfirmAccount = accountId;
|
||||||
|
return learnResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -74,8 +82,28 @@ class FakeInboxController extends InboxController {
|
|||||||
Future<void> ignore(RawMessage message) async {
|
Future<void> ignore(RawMessage message) async {
|
||||||
ignoreCalls++;
|
ignoreCalls++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> retry(RawMessage message) async {
|
||||||
|
retryCalls++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Сообщение, застрявшее в «ждёт сети» (pendingAi): draftJson ещё нет.
|
||||||
|
RawMessage _pendingAiMessage({int attempts = 0, String? lastParseError}) =>
|
||||||
|
RawMessage(
|
||||||
|
id: 'msg3',
|
||||||
|
userId: 'u1',
|
||||||
|
packageName: 'ru.sberbankmobile',
|
||||||
|
body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
|
||||||
|
receivedAt: _now,
|
||||||
|
dedupHash: 'h3',
|
||||||
|
status: RawMessageStatus.pendingAi,
|
||||||
|
parseAttemptCount: attempts,
|
||||||
|
lastParseError: lastParseError,
|
||||||
|
createdAt: _now,
|
||||||
|
);
|
||||||
|
|
||||||
RawMessage _recognizedMessage() {
|
RawMessage _recognizedMessage() {
|
||||||
final draft = ParseDraft(
|
final draft = ParseDraft(
|
||||||
rawMessageId: 'msg1',
|
rawMessageId: 'msg1',
|
||||||
@@ -232,6 +260,37 @@ void main() {
|
|||||||
expect(fake.createRuleCalls, 0);
|
expect(fake.createRuleCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('confirm показывает SnackBar, когда контроллер выучил дефолт',
|
||||||
|
(tester) async {
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||||
|
fake.learnResult = true;
|
||||||
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.check));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(SnackBar),
|
||||||
|
matching: find.text(l10n.inboxAppDefaultSet),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('confirm без обучения дефолта — SnackBar не показывается',
|
||||||
|
(tester) async {
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||||
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.check));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(l10n.inboxAppDefaultSet), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('tap ignore calls controller', (tester) async {
|
testWidgets('tap ignore calls controller', (tester) async {
|
||||||
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -494,4 +553,42 @@ void main() {
|
|||||||
expect(find.text(l10n.inboxUnrecognized), findsOneWidget);
|
expect(find.text(l10n.inboxUnrecognized), findsOneWidget);
|
||||||
expect(find.text(l10n.inboxAddManually), findsOneWidget);
|
expect(find.text(l10n.inboxAddManually), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('pendingAi offline: причина «нет сети», ретрай зовёт контроллер',
|
||||||
|
(tester) async {
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||||
|
await tester.pumpWidget(_host(
|
||||||
|
fake,
|
||||||
|
_pendingAiMessage(lastParseError: 'offline'),
|
||||||
|
extraOverrides: [
|
||||||
|
// connectivity_plus не работает под flutter test — оверрайдим явно.
|
||||||
|
isOnlineProvider.overrideWith((ref) => Stream.value(false)),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(l10n.inboxWaitingNetworkTitle), findsOneWidget);
|
||||||
|
expect(find.text(l10n.inboxStuckOffline), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.refresh));
|
||||||
|
await tester.pump();
|
||||||
|
expect(fake.retryCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pendingAi при живой сети: причина «запрос не прошёл» + попытки',
|
||||||
|
(tester) async {
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||||
|
await tester.pumpWidget(_host(
|
||||||
|
fake,
|
||||||
|
_pendingAiMessage(attempts: 2, lastParseError: 'network'),
|
||||||
|
extraOverrides: [
|
||||||
|
isOnlineProvider.overrideWith((ref) => Stream.value(true)),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(l10n.inboxWaitingNetworkTitle), findsOneWidget);
|
||||||
|
expect(find.text(l10n.inboxStuckNetwork), findsOneWidget);
|
||||||
|
expect(find.text(l10n.parsingDetailAttempt(2, 5)), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user