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)",
|
||||
"Read(//c/Users/user/AppData/Local/Pub/Cache/**)",
|
||||
"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/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
|
||||
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/daos/ # *_dao.dart with .watch*() methods
|
||||
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);
|
||||
every create path must pass `packageName`),
|
||||
`rule_candidates` (NOT app-scoped — key is `userId+kind+rawValue`),
|
||||
`account_bindings` (+`isDefault` per-app default binding),
|
||||
`source_apps` (allowlist of monitored apps; +`selfMerchant` — "merchant is the app itself",
|
||||
e.g. Ozon: suppresses AI category prefill and the rule suggestion in Inbox),
|
||||
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
|
||||
in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
|
||||
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 —
|
||||
a checklist of named `AutoApplyCheck`s (rule matched, amount literally found in body,
|
||||
currency known, draft type == rule `txType` (null = skip), account resolved+trusted,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
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 {
|
||||
namespace = "com.sanders.budget.new_budget"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -25,11 +35,24 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
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">
|
||||
<!-- В release-манифест Flutter НЕ добавляет INTERNET автоматически (в отличие
|
||||
от debug/profile) — без этой строки все сетевые вызовы падают. -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
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 определения счёта при парсинге уведомлений
|
||||
|
||||
> ⚠️ **Реализовано и затем упрощено (2026-07):** описанная здесь модель привязок
|
||||
> `account_bindings` (карта/телефон → счёт, per-app default со звёздочкой) удалена в
|
||||
> schemaVersion 2. Действующая модель: правило `senderToAccount` →
|
||||
> `source_apps.defaultAccountId` → глобальный дефолт (не trusted). Документ — история.
|
||||
|
||||
## Context
|
||||
|
||||
Приложение парсит уведомления банков и создаёт черновики транзакций. Проблемы по текущему коду:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Парсинг 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.
|
||||
|
||||
**Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз.** При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
> Рекап по спецификации [notification_parsing.md](notification_parsing.md).
|
||||
> Актуально на **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",
|
||||
"analyticsSubtitle": "Analytics",
|
||||
"analyticsNoData": "No data for this month",
|
||||
|
||||
"accountsSubtitle": "Management",
|
||||
|
||||
@@ -221,7 +222,7 @@
|
||||
"gateCheckCurrencyKnown": "currency not recognized",
|
||||
"gateCheckTypeMatchesRule": "transaction type differs from the rule",
|
||||
"gateCheckAccountResolved": "account not resolved",
|
||||
"gateCheckAccountTrusted": "ambiguous account",
|
||||
"gateCheckAccountTrusted": "account not confirmed for this app",
|
||||
"gateCheckAmountUnderCap": "amount is too large",
|
||||
"parsingRulesTile": "Parsing rules",
|
||||
"parsingRulesCreatedCount": "Rules created: {count}",
|
||||
@@ -317,6 +318,12 @@
|
||||
"inboxNoCategory": "No category",
|
||||
"inboxAccountUnknown": "Account unknown",
|
||||
"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",
|
||||
"rulesSubtitle": "Settings",
|
||||
@@ -432,14 +439,14 @@
|
||||
"sourceAppsSelfMerchantLabel": "Merchant is the app itself",
|
||||
"sourceAppsSelfMerchantHint": "Notifications never name the seller: pick the category manually, no rules are suggested",
|
||||
|
||||
"appBindingsTitle": "Account bindings",
|
||||
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
|
||||
"appBindingsAnyCard": "Any card (default)",
|
||||
"appBindingsSetDefault": "Set as default for this app",
|
||||
"appBindingsAddTitle": "New binding",
|
||||
"appBindingsCardLabel": "Card last 4 digits",
|
||||
"appBindingsCardHelper": "Leave empty to match any card",
|
||||
"appBindingsDefaultLabel": "Default for this app",
|
||||
"appDetailEnabled": "Monitor notifications",
|
||||
"appDetailDefaultAccount": "Default account",
|
||||
"appDetailDefaultAccountNone": "Not set",
|
||||
"appDetailDefaultAccountHint": "New transactions from this app go to this account. It is remembered automatically the first time you confirm a message in the Inbox.",
|
||||
"appDetailRoutingRules": "Account routing rules",
|
||||
"appDetailRoutingRulesEmpty": "If the app reports several accounts (card, deposit), add a rule: text pattern → account. Rules override the default account.",
|
||||
"appDetailAddRule": "Add",
|
||||
"inboxAppDefaultSet": "Account saved as the app's default",
|
||||
|
||||
"ruleKindMerchant": "Merchant → category",
|
||||
"ruleKindAccount": "Sender → account",
|
||||
|
||||
@@ -236,6 +236,12 @@ abstract class AppLocalizations {
|
||||
/// **'Аналитика'**
|
||||
String get analyticsSubtitle;
|
||||
|
||||
/// No description provided for @analyticsNoData.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Нет данных за этот месяц'**
|
||||
String get analyticsNoData;
|
||||
|
||||
/// No description provided for @accountsSubtitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -995,7 +1001,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @gateCheckAccountTrusted.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'счёт неоднозначен'**
|
||||
/// **'счёт не подтверждён для приложения'**
|
||||
String get gateCheckAccountTrusted;
|
||||
|
||||
/// No description provided for @gateCheckAmountUnderCap.
|
||||
@@ -1520,6 +1526,42 @@ abstract class AppLocalizations {
|
||||
/// **'Изменить перед сохранением'**
|
||||
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.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -2036,53 +2078,53 @@ abstract class AppLocalizations {
|
||||
/// **'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'**
|
||||
String get sourceAppsSelfMerchantHint;
|
||||
|
||||
/// No description provided for @appBindingsTitle.
|
||||
/// No description provided for @appDetailEnabled.
|
||||
///
|
||||
/// 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:
|
||||
/// **'Пока нет привязок. Добавьте, чтобы связать карту со счётом.'**
|
||||
String get appBindingsEmpty;
|
||||
/// **'Счёт по умолчанию'**
|
||||
String get appDetailDefaultAccount;
|
||||
|
||||
/// No description provided for @appBindingsAnyCard.
|
||||
/// No description provided for @appDetailDefaultAccountNone.
|
||||
///
|
||||
/// 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:
|
||||
/// **'Сделать счётом по умолчанию для приложения'**
|
||||
String get appBindingsSetDefault;
|
||||
/// **'Новые операции из этого приложения записываются на этот счёт. Запоминается автоматически при первом «Подтвердить» во Входящих.'**
|
||||
String get appDetailDefaultAccountHint;
|
||||
|
||||
/// No description provided for @appBindingsAddTitle.
|
||||
/// No description provided for @appDetailRoutingRules.
|
||||
///
|
||||
/// 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:
|
||||
/// **'Последние 4 цифры карты'**
|
||||
String get appBindingsCardLabel;
|
||||
/// **'Если приложение пишет о нескольких счетах (карта, вклад), добавьте правило: паттерн в тексте → счёт. Правила важнее счёта по умолчанию.'**
|
||||
String get appDetailRoutingRulesEmpty;
|
||||
|
||||
/// No description provided for @appBindingsCardHelper.
|
||||
/// No description provided for @appDetailAddRule.
|
||||
///
|
||||
/// 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:
|
||||
/// **'По умолчанию для приложения'**
|
||||
String get appBindingsDefaultLabel;
|
||||
/// **'Счёт сохранён как основной для приложения'**
|
||||
String get inboxAppDefaultSet;
|
||||
|
||||
/// No description provided for @ruleKindMerchant.
|
||||
///
|
||||
|
||||
@@ -105,6 +105,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get analyticsSubtitle => 'Analytics';
|
||||
|
||||
@override
|
||||
String get analyticsNoData => 'No data for this month';
|
||||
|
||||
@override
|
||||
String get accountsSubtitle => 'Management';
|
||||
|
||||
@@ -519,7 +522,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get gateCheckAccountResolved => 'account not resolved';
|
||||
|
||||
@override
|
||||
String get gateCheckAccountTrusted => 'ambiguous account';
|
||||
String get gateCheckAccountTrusted => 'account not confirmed for this app';
|
||||
|
||||
@override
|
||||
String get gateCheckAmountUnderCap => 'amount is too large';
|
||||
@@ -795,6 +798,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get rulesTitle => 'Parsing rules';
|
||||
|
||||
@@ -972,7 +995,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get habitTrackingTile => 'Spending habit analysis';
|
||||
|
||||
@override
|
||||
String get habitObligationRequired => 'Necessary';
|
||||
String get habitObligationRequired => 'Required';
|
||||
|
||||
@override
|
||||
String get habitObligationOptional => 'Optional';
|
||||
@@ -987,7 +1010,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get habitImpulseImpulsive => 'Impulse';
|
||||
|
||||
@override
|
||||
String get habitImpulseConsidered => 'Considered';
|
||||
String get habitImpulseConsidered => 'Thoughtful';
|
||||
|
||||
@override
|
||||
String get habitNotMarked => 'not marked';
|
||||
@@ -999,7 +1022,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get habitScaleImpulseCaps => 'IMPULSIVENESS';
|
||||
|
||||
@override
|
||||
String get habitScaleObligationCaps => 'OBLIGATION';
|
||||
String get habitScaleObligationCaps => 'NECESSITY';
|
||||
|
||||
@override
|
||||
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';
|
||||
|
||||
@override
|
||||
String get appBindingsTitle => 'Account bindings';
|
||||
String get appDetailEnabled => 'Monitor notifications';
|
||||
|
||||
@override
|
||||
String get appBindingsEmpty =>
|
||||
'No bindings yet. Add one to map a card to an account.';
|
||||
String get appDetailDefaultAccount => 'Default account';
|
||||
|
||||
@override
|
||||
String get appBindingsAnyCard => 'Any card (default)';
|
||||
String get appDetailDefaultAccountNone => 'Not set';
|
||||
|
||||
@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
|
||||
String get appBindingsAddTitle => 'New binding';
|
||||
String get appDetailRoutingRules => 'Account routing rules';
|
||||
|
||||
@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
|
||||
String get appBindingsCardHelper => 'Leave empty to match any card';
|
||||
String get appDetailAddRule => 'Add';
|
||||
|
||||
@override
|
||||
String get appBindingsDefaultLabel => 'Default for this app';
|
||||
String get inboxAppDefaultSet => 'Account saved as the app\'s default';
|
||||
|
||||
@override
|
||||
String get ruleKindMerchant => 'Merchant → category';
|
||||
|
||||
@@ -111,6 +111,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get analyticsSubtitle => 'Аналитика';
|
||||
|
||||
@override
|
||||
String get analyticsNoData => 'Нет данных за этот месяц';
|
||||
|
||||
@override
|
||||
String get accountsSubtitle => 'Управление';
|
||||
|
||||
@@ -530,7 +533,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get gateCheckAccountResolved => 'счёт не определён';
|
||||
|
||||
@override
|
||||
String get gateCheckAccountTrusted => 'счёт неоднозначен';
|
||||
String get gateCheckAccountTrusted => 'счёт не подтверждён для приложения';
|
||||
|
||||
@override
|
||||
String get gateCheckAmountUnderCap => 'слишком крупная сумма';
|
||||
@@ -806,6 +809,26 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get rulesTitle => 'Правила парсинга';
|
||||
|
||||
@@ -1083,30 +1106,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются';
|
||||
|
||||
@override
|
||||
String get appBindingsTitle => 'Привязки счетов';
|
||||
String get appDetailEnabled => 'Отслеживать уведомления';
|
||||
|
||||
@override
|
||||
String get appBindingsEmpty =>
|
||||
'Пока нет привязок. Добавьте, чтобы связать карту со счётом.';
|
||||
String get appDetailDefaultAccount => 'Счёт по умолчанию';
|
||||
|
||||
@override
|
||||
String get appBindingsAnyCard => 'Любая карта (по умолчанию)';
|
||||
String get appDetailDefaultAccountNone => 'Не задан';
|
||||
|
||||
@override
|
||||
String get appBindingsSetDefault =>
|
||||
'Сделать счётом по умолчанию для приложения';
|
||||
String get appDetailDefaultAccountHint =>
|
||||
'Новые операции из этого приложения записываются на этот счёт. Запоминается автоматически при первом «Подтвердить» во Входящих.';
|
||||
|
||||
@override
|
||||
String get appBindingsAddTitle => 'Новая привязка';
|
||||
String get appDetailRoutingRules => 'Правила выбора счёта';
|
||||
|
||||
@override
|
||||
String get appBindingsCardLabel => 'Последние 4 цифры карты';
|
||||
String get appDetailRoutingRulesEmpty =>
|
||||
'Если приложение пишет о нескольких счетах (карта, вклад), добавьте правило: паттерн в тексте → счёт. Правила важнее счёта по умолчанию.';
|
||||
|
||||
@override
|
||||
String get appBindingsCardHelper => 'Оставьте пустым для любой карты';
|
||||
String get appDetailAddRule => 'Добавить';
|
||||
|
||||
@override
|
||||
String get appBindingsDefaultLabel => 'По умолчанию для приложения';
|
||||
String get inboxAppDefaultSet => 'Счёт сохранён как основной для приложения';
|
||||
|
||||
@override
|
||||
String get ruleKindMerchant => 'Мерчант → категория';
|
||||
|
||||
+16
-9
@@ -69,6 +69,7 @@
|
||||
|
||||
"analyticsTitle": "Отчёты",
|
||||
"analyticsSubtitle": "Аналитика",
|
||||
"analyticsNoData": "Нет данных за этот месяц",
|
||||
|
||||
"accountsSubtitle": "Управление",
|
||||
|
||||
@@ -221,7 +222,7 @@
|
||||
"gateCheckCurrencyKnown": "валюта не распознана",
|
||||
"gateCheckTypeMatchesRule": "тип операции отличается от правила",
|
||||
"gateCheckAccountResolved": "счёт не определён",
|
||||
"gateCheckAccountTrusted": "счёт неоднозначен",
|
||||
"gateCheckAccountTrusted": "счёт не подтверждён для приложения",
|
||||
"gateCheckAmountUnderCap": "слишком крупная сумма",
|
||||
"parsingRulesTile": "Правила парсинга",
|
||||
"parsingRulesCreatedCount": "Правил создано: {count}",
|
||||
@@ -317,6 +318,12 @@
|
||||
"inboxNoCategory": "Без категории",
|
||||
"inboxAccountUnknown": "Счёт не определён",
|
||||
"inboxEditRuleTooltip": "Изменить перед сохранением",
|
||||
"inboxWaitingNetworkTitle": "Ждёт сети",
|
||||
"inboxStuckOffline": "Нет подключения к интернету. Разбор продолжится, когда появится сеть.",
|
||||
"inboxStuckNetwork": "Сеть есть, но запрос к AI не прошёл. Повторим автоматически.",
|
||||
"parseErrorOffline": "Не было соединения с интернетом",
|
||||
"parseErrorNetwork": "Запрос к AI не прошёл (ошибка сети)",
|
||||
"parseErrorRetryLimit": "Лимит AI-попыток исчерпан",
|
||||
|
||||
"rulesTitle": "Правила парсинга",
|
||||
"rulesSubtitle": "Настройки",
|
||||
@@ -432,14 +439,14 @@
|
||||
"sourceAppsSelfMerchantLabel": "Мерчант — само приложение",
|
||||
"sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются",
|
||||
|
||||
"appBindingsTitle": "Привязки счетов",
|
||||
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
|
||||
"appBindingsAnyCard": "Любая карта (по умолчанию)",
|
||||
"appBindingsSetDefault": "Сделать счётом по умолчанию для приложения",
|
||||
"appBindingsAddTitle": "Новая привязка",
|
||||
"appBindingsCardLabel": "Последние 4 цифры карты",
|
||||
"appBindingsCardHelper": "Оставьте пустым для любой карты",
|
||||
"appBindingsDefaultLabel": "По умолчанию для приложения",
|
||||
"appDetailEnabled": "Отслеживать уведомления",
|
||||
"appDetailDefaultAccount": "Счёт по умолчанию",
|
||||
"appDetailDefaultAccountNone": "Не задан",
|
||||
"appDetailDefaultAccountHint": "Новые операции из этого приложения записываются на этот счёт. Запоминается автоматически при первом «Подтвердить» во Входящих.",
|
||||
"appDetailRoutingRules": "Правила выбора счёта",
|
||||
"appDetailRoutingRulesEmpty": "Если приложение пишет о нескольких счетах (карта, вклад), добавьте правило: паттерн в тексте → счёт. Правила важнее счёта по умолчанию.",
|
||||
"appDetailAddRule": "Добавить",
|
||||
"inboxAppDefaultSet": "Счёт сохранён как основной для приложения",
|
||||
|
||||
"ruleKindMerchant": "Мерчант → категория",
|
||||
"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/home/presentation/screens/home_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/inbox_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/rule_editor_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/profile/presentation/screens/profile_screen.dart';
|
||||
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
|
||||
@@ -75,10 +75,7 @@ GoRouter appRouter(Ref ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.categoriesList,
|
||||
pageBuilder: (context, state) => _slideUpPage<void>(
|
||||
state,
|
||||
const CategoriesListScreen(),
|
||||
),
|
||||
builder: (context, state) => const CategoriesListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.categoryNew,
|
||||
@@ -110,8 +107,7 @@ GoRouter appRouter(Ref ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.inbox,
|
||||
pageBuilder: (context, state) =>
|
||||
_slideUpPage<void>(state, const InboxScreen()),
|
||||
builder: (context, state) => const InboxScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.parsingSettings,
|
||||
@@ -149,15 +145,14 @@ GoRouter appRouter(Ref ref) {
|
||||
builder: (context, state) => const SourceAppsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.parsingAppBindingsPattern,
|
||||
builder: (context, state) => AppBindingsScreen(
|
||||
path: AppRoutes.parsingAppDetailPattern,
|
||||
builder: (context, state) => SourceAppDetailScreen(
|
||||
packageName: Uri.decodeComponent(state.pathParameters['pkg']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.habitAnalysis,
|
||||
pageBuilder: (context, state) =>
|
||||
_slideUpPage<void>(state, const HabitAnalysisScreen()),
|
||||
builder: (context, state) => const HabitAnalysisScreen(),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => AppScaffold(
|
||||
|
||||
@@ -32,7 +32,7 @@ class AppRoutes {
|
||||
static const parsingDebugInject = '/settings/parsing/debug';
|
||||
static const parsingLog = '/settings/parsing/log';
|
||||
static const parsingApps = '/settings/parsing/apps';
|
||||
static const parsingAppBindingsPattern = '/settings/parsing/apps/:pkg';
|
||||
static String parsingAppBindings(String packageName) =>
|
||||
static const parsingAppDetailPattern = '/settings/parsing/apps/:pkg';
|
||||
static String parsingAppDetail(String packageName) =>
|
||||
'/settings/parsing/apps/${Uri.encodeComponent(packageName)}';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'converters/enum_converters.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/parse_rules_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/transfer_pairing_blocklist_table.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/rule_candidates_dao.dart';
|
||||
import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart';
|
||||
import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart';
|
||||
import '../../features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart';
|
||||
|
||||
@@ -44,7 +43,6 @@ part 'app_database.g.dart';
|
||||
RawMessagesTable,
|
||||
ParseRulesTable,
|
||||
RuleCandidatesTable,
|
||||
AccountBindingsTable,
|
||||
SourceAppsTable,
|
||||
TransferPairingBlocklistTable,
|
||||
],
|
||||
@@ -58,7 +56,6 @@ part 'app_database.g.dart';
|
||||
RawMessagesDao,
|
||||
ParseRulesDao,
|
||||
RuleCandidatesDao,
|
||||
AccountBindingsDao,
|
||||
SourceAppsDao,
|
||||
TransferPairingBlocklistDao,
|
||||
],
|
||||
@@ -70,15 +67,106 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) async {
|
||||
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() {
|
||||
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/router/app_routes.dart';
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
import '../../../settings/application/settings_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 {
|
||||
const AnalyticsScreen({super.key});
|
||||
|
||||
@@ -25,64 +26,31 @@ class AnalyticsScreen extends ConsumerWidget {
|
||||
false)
|
||||
: false;
|
||||
|
||||
return PlaceholderScreen(
|
||||
subtitle: l10n.analyticsSubtitle,
|
||||
title: l10n.analyticsTitle,
|
||||
body: !habitEnabled
|
||||
? null
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: _HubTile(
|
||||
icon: Icons.insights_outlined,
|
||||
title: l10n.habitAnalysisTile,
|
||||
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),
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
appBar: AppBar(
|
||||
backgroundColor: p.paper,
|
||||
title: Text(l10n.analyticsTitle),
|
||||
actions: const [
|
||||
MonthStepper(),
|
||||
SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
body: userId == null
|
||||
? const SizedBox.shrink()
|
||||
: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
children: [
|
||||
if (habitEnabled)
|
||||
ChartCard(
|
||||
title: l10n.habitAnalysisTile,
|
||||
leadingIcon: Icons.insights_outlined,
|
||||
onTap: () => context.push(AppRoutes.habitAnalysis),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 '../../../categories/application/categories_controller.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/money_text.dart';
|
||||
import '../../../home/presentation/widgets/tx_row.dart';
|
||||
import '../../../transactions/domain/entities/transaction.dart';
|
||||
import '../../../user/application/active_user_controller.dart';
|
||||
import '../../application/habit_analysis_providers.dart';
|
||||
import '../widgets/month_stepper.dart';
|
||||
|
||||
/// Подэкран «Анализ привычек»: фильтры по двум шкалам + список расходов за месяц.
|
||||
class HabitAnalysisScreen extends ConsumerWidget {
|
||||
@@ -28,6 +28,14 @@ class HabitAnalysisScreen extends ConsumerWidget {
|
||||
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
appBar: AppBar(
|
||||
backgroundColor: p.paper,
|
||||
title: Text(context.l10n.habitAnalysisTitle),
|
||||
actions: const [
|
||||
MonthStepper(),
|
||||
SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
body: userId == null
|
||||
? const SizedBox.shrink()
|
||||
: SafeArea(child: _Body(userId: userId)),
|
||||
@@ -57,7 +65,6 @@ class _Body extends ConsumerWidget {
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _HeaderBar(userId: userId)),
|
||||
SliverToBoxAdapter(child: _FilterBlock(userId: userId)),
|
||||
SliverToBoxAdapter(child: _SummaryRow(userId: userId)),
|
||||
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();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
backgroundColor: p.paper,
|
||||
appBar: AppBar(
|
||||
backgroundColor: p.paper,
|
||||
title: Text(l10n.categoriesScreenTitle),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push(AppRoutes.categoryNew),
|
||||
backgroundColor: p.accent,
|
||||
@@ -34,15 +38,7 @@ class CategoriesListScreen extends ConsumerWidget {
|
||||
error: (e, _) => Center(child: Text('$e')),
|
||||
data: (user) {
|
||||
if (user == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
_Header(
|
||||
title: l10n.categoriesScreenTitle,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(child: _CategoriesBody(userId: user.id)),
|
||||
],
|
||||
);
|
||||
return _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 + удаление кандидата.
|
||||
/// Все последующие похожие сообщения auto-apply молча.
|
||||
Future<void> createRule({
|
||||
///
|
||||
/// Возвращает true, если выбранный счёт был записан дефолтом приложения
|
||||
/// (авто-обучение) — карточка показывает SnackBar.
|
||||
Future<bool> createRule({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required ParseDraft draft,
|
||||
@@ -74,7 +77,7 @@ class InboxController extends _$InboxController {
|
||||
required String merchantCanonical,
|
||||
required String pattern,
|
||||
MatchMode matchMode = MatchMode.contains,
|
||||
bool bindAccount = true,
|
||||
bool learnAppDefault = true,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
@@ -108,8 +111,10 @@ class InboxController extends _$InboxController {
|
||||
}
|
||||
|
||||
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);
|
||||
return learned;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
@@ -118,13 +123,15 @@ class InboxController extends _$InboxController {
|
||||
|
||||
/// «Подтвердить разово»: только транзакция (правило не создаём),
|
||||
/// усиливаем кандидата для будущего предложения.
|
||||
Future<void> confirmOnce({
|
||||
///
|
||||
/// Возвращает true, если счёт записан дефолтом приложения (см. [createRule]).
|
||||
Future<bool> confirmOnce({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required ParseDraft draft,
|
||||
required String accountId,
|
||||
String? categoryId,
|
||||
bool bindAccount = true,
|
||||
bool learnAppDefault = true,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
@@ -149,8 +156,10 @@ class InboxController extends _$InboxController {
|
||||
resolvedValue: categoryId,
|
||||
);
|
||||
}
|
||||
if (bindAccount) await _maybeBind(userId, message, draft, accountId);
|
||||
final learned = learnAppDefault &&
|
||||
await _maybeSetAppDefault(userId, message, accountId);
|
||||
state = const AsyncData(null);
|
||||
return learned;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
@@ -389,25 +398,18 @@ class InboxController extends _$InboxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее
|
||||
/// сообщение того же мерчанта прошло gate молча (account score 100).
|
||||
Future<void> _maybeBind(
|
||||
/// Авто-обучение дефолтного счёта приложения: первый Confirm по приложению
|
||||
/// без дефолта запоминает выбранный счёт — следующие сообщения резолвятся
|
||||
/// trusted и могут авто-применяться. true = дефолт записан только что.
|
||||
Future<bool> _maybeSetAppDefault(
|
||||
String userId,
|
||||
RawMessage message,
|
||||
ParseDraft draft,
|
||||
String accountId,
|
||||
) async {
|
||||
final last4 = draft.cardLast4;
|
||||
if (last4 == null) return;
|
||||
final bindings = ref.read(accountBindingsRepositoryProvider);
|
||||
final existing = await bindings.findByPackageAndCard(
|
||||
userId, message.packageName, last4);
|
||||
if (existing != null) return;
|
||||
await bindings.create(
|
||||
userId: userId,
|
||||
packageName: message.packageName,
|
||||
cardLast4: last4,
|
||||
accountId: accountId,
|
||||
);
|
||||
final repo = ref.read(sourceAppsRepositoryProvider);
|
||||
final app = await repo.findByPackageName(userId, message.packageName);
|
||||
if (app == null || app.defaultAccountId != null) return false;
|
||||
await repo.setDefaultAccount(app.id, accountId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/providers/database_provider.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/raw_messages_repository_impl.dart';
|
||||
import '../data/repositories/rule_candidates_repository_impl.dart';
|
||||
import '../data/repositories/source_apps_repository_impl.dart';
|
||||
import '../data/repositories/transfer_pairing_blocklist_repository_impl.dart';
|
||||
import '../domain/repositories/account_bindings_repository.dart';
|
||||
import '../domain/repositories/parse_rules_repository.dart';
|
||||
import '../domain/repositories/raw_messages_repository.dart';
|
||||
import '../domain/repositories/rule_candidates_repository.dart';
|
||||
@@ -32,11 +30,6 @@ RuleCandidatesRepository ruleCandidatesRepository(Ref ref) =>
|
||||
RuleCandidatesRepositoryImpl(
|
||||
ref.watch(appDatabaseProvider).ruleCandidatesDao);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AccountBindingsRepository accountBindingsRepository(Ref ref) =>
|
||||
AccountBindingsRepositoryImpl(
|
||||
ref.watch(appDatabaseProvider).accountBindingsDao);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SourceAppsRepository sourceAppsRepository(Ref ref) =>
|
||||
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../domain/entities/parse_draft.dart';
|
||||
import '../domain/entities/raw_message.dart';
|
||||
import '../domain/entities/rule_candidate.dart';
|
||||
import '../domain/entities/rule_suggestion.dart';
|
||||
import '../domain/entities/source_app.dart';
|
||||
import '../domain/enums.dart';
|
||||
import 'ai_providers.dart';
|
||||
import 'notification_parsing_providers.dart';
|
||||
@@ -78,8 +79,7 @@ class ParsingPipeline {
|
||||
}
|
||||
|
||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||
await _extractViaAi(userId, msg, settings,
|
||||
selfMerchant: sourceApp.selfMerchant);
|
||||
await _extractViaAi(userId, msg, settings, sourceApp: sourceApp);
|
||||
}
|
||||
|
||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||
@@ -88,7 +88,7 @@ class ParsingPipeline {
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParsingSettings settings, {
|
||||
required bool selfMerchant,
|
||||
required SourceApp sourceApp,
|
||||
}) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
||||
@@ -165,7 +165,7 @@ class ParsingPipeline {
|
||||
);
|
||||
case AiParseStatus.draft:
|
||||
await _runPipeline(userId, msg, outcome.draft!, settings,
|
||||
categories: categories, selfMerchant: selfMerchant);
|
||||
categories: categories, sourceApp: sourceApp);
|
||||
}
|
||||
} on DeepSeekNetworkException {
|
||||
await repo.updateAfterParse(
|
||||
@@ -194,18 +194,19 @@ class ParsingPipeline {
|
||||
|
||||
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
||||
///
|
||||
/// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon):
|
||||
/// AI-подсказка категории глушится, merchant→category правило не
|
||||
/// предлагается — категорию пользователь выбирает вручную на карточке.
|
||||
/// [sourceApp] — приложение-источник: `selfMerchant` («мерчант — само
|
||||
/// приложение», Ozon: AI-подсказка категории глушится, merchant→category
|
||||
/// правило не предлагается) и `defaultAccountId` для резолвера счёта.
|
||||
Future<void> _runPipeline(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParseDraft draft0,
|
||||
ParsingSettings settings, {
|
||||
List<Category>? categories,
|
||||
bool selfMerchant = false,
|
||||
required SourceApp sourceApp,
|
||||
}) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final selfMerchant = sourceApp.selfMerchant;
|
||||
|
||||
if (selfMerchant) {
|
||||
// «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому
|
||||
@@ -223,22 +224,18 @@ class ParsingPipeline {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
|
||||
// 3. Разрешение счёта (§B): senderToAccount → дефолт приложения →
|
||||
// глобальный дефолт (не trusted).
|
||||
final globalDefaultAccountId = (await _ref
|
||||
.read(accountRepositoryProvider)
|
||||
.watchDefault(userId)
|
||||
.first)
|
||||
?.id;
|
||||
final resolver =
|
||||
AccountResolver(_ref.read(accountBindingsRepositoryProvider));
|
||||
final resolution = await resolver.resolve(
|
||||
userId: userId,
|
||||
packageName: msg.packageName,
|
||||
final resolution = resolveAccount(
|
||||
body: msg.body,
|
||||
cardLast4: draft0.cardLast4,
|
||||
phone: draft0.counterpartyPhone,
|
||||
merchantRaw: draft0.merchantRaw,
|
||||
senderRules: rules,
|
||||
appDefaultAccountId: sourceApp.defaultAccountId,
|
||||
globalDefaultAccountId: globalDefaultAccountId,
|
||||
);
|
||||
var draft = draft0.copyWith(accountId: resolution.accountId);
|
||||
@@ -341,7 +338,7 @@ class ParsingPipeline {
|
||||
);
|
||||
|
||||
if (gate.decision == GateDecision.autoApply) {
|
||||
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
|
||||
await _autoApply(userId, msg, draft, rule!, scores);
|
||||
} else {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
@@ -371,7 +368,6 @@ class ParsingPipeline {
|
||||
ParseDraft draft,
|
||||
rule,
|
||||
FieldScores scores,
|
||||
String? bindingId,
|
||||
) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final tx = await _ref
|
||||
@@ -404,11 +400,6 @@ class ParsingPipeline {
|
||||
await _ref
|
||||
.read(parseRulesRepositoryProvider)
|
||||
.incrementMatchCount(rule.id, DateTime.now());
|
||||
if (bindingId != null) {
|
||||
await _ref
|
||||
.read(accountBindingsRepositoryProvider)
|
||||
.incrementMatchCount(bindingId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfer pairing ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +11,16 @@ import 'parsing_settings_controller.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. Слушает
|
||||
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
||||
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
||||
@@ -20,7 +30,7 @@ part 'parsing_worker.g.dart';
|
||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||
/// Провайдер `keepAlive` — активируется `ref.watch` из AppScaffold.
|
||||
///
|
||||
/// Оба входа (pending и waitingPair) — прямые подписки на Drift-стримы
|
||||
/// Все входы (pending, pendingAi и waitingPair) — прямые подписки на Drift-стримы
|
||||
/// репозитория, БЕЗ промежуточных autoDispose stream-провайдеров: у тех нет
|
||||
/// UI-слушателей, а внутренний `ref.listen` приостановленного воркера (bare
|
||||
/// `ProviderContainer` в тестах) их не активирует — pause-семантика riverpod 3.
|
||||
@@ -40,6 +50,16 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
bool _sweepRequested = false;
|
||||
Timer? _pairTimer;
|
||||
|
||||
// Level-триггер авторетрая `pending_ai` (§7): edge-реквью по сети бессилен,
|
||||
// когда connectivity считает сеть живой, а запросы фактически падают
|
||||
// (транзиентный сбой, сеть без интернета). Пока есть pending_ai-строки,
|
||||
// таймер с экспоненциальным бэкоффом прогоняет их через pipeline напрямую.
|
||||
// Кэп реальных попыток (5) в pipeline переводит хронику в failed — цикл
|
||||
// конечен; счётчик попыток при авторетрае НЕ сбрасывается (в отличие от
|
||||
// ручного `resetForRetry`).
|
||||
Timer? _aiRetryTimer;
|
||||
Duration? _aiRetryDelay; // null = базовая задержка (бэкофф сброшен)
|
||||
|
||||
@override
|
||||
void build(String userId) {
|
||||
// Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
|
||||
@@ -86,6 +106,27 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
);
|
||||
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 (новая половинка, склейка,
|
||||
// релиз) запускает sweep и перевзводит таймер ближайшего дедлайна.
|
||||
// Первая эмиссия покрывает старт приложения: дедлайны в БД, рестарт
|
||||
@@ -163,8 +204,44 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
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).
|
||||
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 list = await repo.watchPendingAi(userId).first;
|
||||
for (final m in list) {
|
||||
|
||||
@@ -41,6 +41,9 @@ class SourceAppsController extends _$SourceAppsController {
|
||||
Future<void> setSelfMerchant(String id, {required bool 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) =>
|
||||
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();
|
||||
|
||||
/// Поток сообщений, ожидающих внимания в Inbox: распознанные (`inbox`),
|
||||
/// неполные (`parsed_partial`) и ошибочные (`failed`) — последние две для
|
||||
/// AI-ветки (§7) показываются с сырым текстом и кнопкой «попробовать снова».
|
||||
/// неполные (`parsed_partial`), ошибочные (`failed`) и ждущие сети
|
||||
/// (`pending_ai`) — последние три для AI-ветки (§7) показываются с сырым
|
||||
/// текстом и кнопкой «попробовать снова».
|
||||
Stream<List<RawMessagesTableData>> watchInbox(String userId) =>
|
||||
(select(rawMessagesTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) &
|
||||
(t.status.equalsValue(RawMessageStatus.inbox) |
|
||||
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)]))
|
||||
.watch();
|
||||
|
||||
@@ -65,11 +67,12 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
..limit(limit))
|
||||
.watch();
|
||||
|
||||
/// Реактивный счётчик для бэджа на Home (inbox + parsed_partial + failed).
|
||||
/// Реактивный счётчик для бэджа на Home. Список статусов должен совпадать
|
||||
/// с [watchInbox] (inbox + parsed_partial + failed + pending_ai).
|
||||
Stream<int> watchInboxCount(String userId) {
|
||||
final query = customSelect(
|
||||
'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)],
|
||||
readsFrom: {rawMessagesTable},
|
||||
);
|
||||
|
||||
@@ -48,6 +48,11 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
|
||||
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||
.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) =>
|
||||
(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).
|
||||
/// Nullable в SQL (упрощает миграцию), но все пути создания обязаны
|
||||
/// передавать значение; NULL-строки матчатся в любом приложении (легаси).
|
||||
/// Без FK на source_apps — зеркалим паттерн account_bindings.
|
||||
/// Без FK на source_apps — удаление приложения правила не трогает.
|
||||
TextColumn get packageName => text().nullable()();
|
||||
|
||||
/// merchantToCategory | senderToAccount | ignore
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import '../../../../../core/database/tables/accounts_table.dart';
|
||||
import '../../../../../core/database/tables/users_table.dart';
|
||||
|
||||
/// Приложение-источник уведомлений, добавленное пользователем (allowlist).
|
||||
@@ -27,6 +28,15 @@ class SourceAppsTable extends Table {
|
||||
/// merchant→category правило для таких источников.
|
||||
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)();
|
||||
|
||||
@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,
|
||||
enabled: enabled,
|
||||
selfMerchant: selfMerchant,
|
||||
defaultAccountId: defaultAccountId,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import '../../domain/entities/parse_rule.dart';
|
||||
import '../../domain/repositories/account_bindings_repository.dart';
|
||||
import 'rule_lookup.dart';
|
||||
|
||||
/// Откуда взят разрешённый счёт (для диагностики / подсветки).
|
||||
enum AccountSource {
|
||||
bindingCard,
|
||||
bindingPhone,
|
||||
senderRule,
|
||||
singleBinding,
|
||||
appDefault,
|
||||
ambiguous,
|
||||
globalDefault,
|
||||
none,
|
||||
}
|
||||
enum AccountSource { senderRule, appDefault, globalDefault, none }
|
||||
|
||||
/// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B).
|
||||
class AccountResolution {
|
||||
@@ -21,7 +11,6 @@ class AccountResolution {
|
||||
required this.score,
|
||||
required this.trusted,
|
||||
required this.source,
|
||||
this.bindingId,
|
||||
});
|
||||
|
||||
/// Разрешённый счёт (null, если ничего не нашлось).
|
||||
@@ -31,125 +20,62 @@ class AccountResolution {
|
||||
final int score;
|
||||
|
||||
/// Можно ли доверять счёту для авто-применения. Именно это поле (а не score)
|
||||
/// решает судьбу в gate: неоднозначность (#5) → false → Inbox; осознанные
|
||||
/// дефолты (#4, #6) → true → авто-применение разрешено.
|
||||
/// решает судьбу в gate: правило и дефолт приложения — осознанный выбор
|
||||
/// пользователя → true; глобальный дефолт — лишь догадка → false → Inbox
|
||||
/// с префиллом (первый Confirm выучит его как дефолт приложения).
|
||||
final bool trusted;
|
||||
|
||||
final AccountSource source;
|
||||
|
||||
/// id сработавшей привязки — для инкремента matchCount.
|
||||
final String? bindingId;
|
||||
}
|
||||
|
||||
/// Разрешает `accountId` по цепочке источников (§B): привязки карта/телефон,
|
||||
/// `senderToAccount`-правила, per-app default и глобальный дефолт счёта.
|
||||
class AccountResolver {
|
||||
const AccountResolver(this._bindings);
|
||||
|
||||
final AccountBindingsRepository _bindings;
|
||||
|
||||
Future<AccountResolution> resolve({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
required String body,
|
||||
String? cardLast4,
|
||||
String? phone,
|
||||
String? merchantRaw,
|
||||
List<ParseRule> senderRules = const [],
|
||||
String? globalDefaultAccountId,
|
||||
}) async {
|
||||
// #1 — binding по packageName + cardLast4.
|
||||
if (cardLast4 != null) {
|
||||
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,
|
||||
/// Разрешает `accountId` по лестнице источников (§B): правило senderToAccount
|
||||
/// (паттерн в теле → счёт) → дефолтный счёт приложения → глобальный дефолтный
|
||||
/// счёт (без доверия) → ничего.
|
||||
AccountResolution resolveAccount({
|
||||
required String body,
|
||||
String? merchantRaw,
|
||||
List<ParseRule> senderRules = const [],
|
||||
String? appDefaultAccountId,
|
||||
String? globalDefaultAccountId,
|
||||
}) {
|
||||
// #1 — 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,
|
||||
);
|
||||
}
|
||||
|
||||
// #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 у легаси-правил — проверка пропускается).
|
||||
typeMatchesRule,
|
||||
|
||||
/// Счёт разрешён (binding / правило / дефолт).
|
||||
/// Счёт разрешён (правило / дефолт приложения / глобальный дефолт).
|
||||
accountResolved,
|
||||
|
||||
/// Счёту можно доверять для авто-применения (не ambiguous multi-binding).
|
||||
/// Счёту можно доверять для авто-применения: правило senderToAccount или
|
||||
/// дефолт приложения; глобальный дефолт — лишь префилл, не доверяем.
|
||||
accountTrusted,
|
||||
|
||||
/// Сумма ниже потолка — защита от галлюцинаций (§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}) =>
|
||||
_dao.setSelfMerchant(id, value: value);
|
||||
|
||||
@override
|
||||
Future<void> setDefaultAccount(String id, String? accountId) =>
|
||||
_dao.setDefaultAccount(id, accountId);
|
||||
|
||||
@override
|
||||
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,
|
||||
/// дополняется account_bindings, rule_lookup, затем передаётся в
|
||||
/// дополняется account_resolver, rule_lookup, затем передаётся в
|
||||
/// confidence_scorer и decision gate.
|
||||
///
|
||||
/// [amount] — минорные единицы (всегда > 0); знак определяется [type].
|
||||
@@ -43,7 +43,7 @@ abstract class ParseDraft with _$ParseDraft {
|
||||
|
||||
// Resolved fields — заполняются на последующих шагах pipeline:
|
||||
|
||||
/// Счёт, разрешённый через account_bindings.
|
||||
/// Счёт, разрешённый резолвером (правило / дефолт приложения / глобальный).
|
||||
String? accountId,
|
||||
|
||||
/// Нормализованное имя мерчанта (из правила или rule_candidate).
|
||||
|
||||
@@ -17,6 +17,11 @@ abstract class SourceApp with _$SourceApp {
|
||||
|
||||
/// Уведомления не называют продавца: мерчант — само приложение (Ozon).
|
||||
@Default(false) bool selfMerchant,
|
||||
|
||||
/// Основной счёт приложения: trusted-ступень резолвера после правил
|
||||
/// senderToAccount. null — не задан (авто-обучится первым «Подтвердить»
|
||||
/// в Inbox). Может висеть на удалённый счёт — UI показывает «Не задан».
|
||||
String? defaultAccountId,
|
||||
required DateTime createdAt,
|
||||
}) = _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, маркетплейсы).
|
||||
Future<void> setSelfMerchant(String id, {required bool value});
|
||||
|
||||
/// Основной счёт приложения (trusted-дефолт резолвера); null — сброс.
|
||||
Future<void> setDefaultAccount(String id, String? accountId);
|
||||
|
||||
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 '../../../user/application/active_user_controller.dart';
|
||||
import '../../application/inbox_controller.dart';
|
||||
import '../../data/parser/draft_codec.dart';
|
||||
import '../../domain/entities/raw_message.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(
|
||||
top: false,
|
||||
child: Padding(
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/enums.dart';
|
||||
import '../widgets/confidence_badge.dart';
|
||||
import '../widgets/gate_check_labels.dart';
|
||||
import '../widgets/parse_error_labels.dart';
|
||||
|
||||
/// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7).
|
||||
const _maxParseAttempts = 5;
|
||||
@@ -182,7 +183,8 @@ class _LogRowState extends ConsumerState<_LogRow> {
|
||||
final merchant = draft?.merchantCanonical ?? draft?.merchantRaw;
|
||||
|
||||
final canRetry = message.status == RawMessageStatus.failed ||
|
||||
message.status == RawMessageStatus.ignored;
|
||||
message.status == RawMessageStatus.ignored ||
|
||||
message.status == RawMessageStatus.pendingAi;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
@@ -259,8 +261,15 @@ class _LogRowState extends ConsumerState<_LogRow> {
|
||||
if (message.lastParseError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message.lastParseError!,
|
||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
||||
parseErrorLabel(context, message.lastParseError!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
// pendingAi — ожидание, не ошибка: не пугаем красным.
|
||||
color: message.status == RawMessageStatus.pendingAi
|
||||
? p.ink2
|
||||
: p.negative,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: _expanded ? null : 2,
|
||||
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/enums.dart';
|
||||
|
||||
/// Предзаполнение редактора при создании правила из Inbox.
|
||||
/// Предзаполнение редактора при создании правила из Inbox (merchant→category
|
||||
/// с паттерном/мерчантом) или с детального экрана приложения (senderToAccount
|
||||
/// с одним лишь packageName). Приложение и вид правила фиксируются.
|
||||
class RuleEditorPrefill {
|
||||
const RuleEditorPrefill({
|
||||
required this.packageName,
|
||||
required this.pattern,
|
||||
required this.merchantCanonical,
|
||||
this.kind = ParseRuleKind.merchantToCategory,
|
||||
this.pattern,
|
||||
this.merchantCanonical,
|
||||
this.categoryId,
|
||||
this.accountId,
|
||||
this.matchMode = MatchMode.contains,
|
||||
});
|
||||
|
||||
final String packageName;
|
||||
final String pattern;
|
||||
final String merchantCanonical;
|
||||
final ParseRuleKind kind;
|
||||
final String? pattern;
|
||||
final String? merchantCanonical;
|
||||
final String? categoryId;
|
||||
final String? accountId;
|
||||
final MatchMode matchMode;
|
||||
@@ -98,8 +102,9 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
final pf = widget.prefill;
|
||||
if (pf != null) {
|
||||
_packageName = pf.packageName;
|
||||
_patternCtrl.text = pf.pattern;
|
||||
_merchantCtrl.text = pf.merchantCanonical;
|
||||
_kind = pf.kind;
|
||||
_patternCtrl.text = pf.pattern ?? '';
|
||||
_merchantCtrl.text = pf.merchantCanonical ?? '';
|
||||
_matchMode = pf.matchMode;
|
||||
_categoryId = pf.categoryId;
|
||||
_accountId = pf.accountId;
|
||||
@@ -231,9 +236,9 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
// Выбор вида — только при создании из списка правил. Из Inbox
|
||||
// (prefill) композим всегда merchant→category: senderToAccount там
|
||||
// не применяется (inbox_controller создаёт правило этого вида).
|
||||
// Выбор вида — только при создании из списка правил. С prefill
|
||||
// вид зафиксирован источником: Inbox — merchant→category, детальный
|
||||
// экран приложения — senderToAccount.
|
||||
if (_appIsPickable) ...[
|
||||
_KindSelector(
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final ctrl = ref.read(sourceAppsControllerProvider.notifier);
|
||||
final title = app.displayName ?? app.packageName;
|
||||
|
||||
// Одна строка: всё остальное (selfMerchant, дефолтный счёт, правила,
|
||||
// удаление) — на детальном экране приложения.
|
||||
return InkWell(
|
||||
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
|
||||
onTap: () => context.push(AppRoutes.parsingAppDetail(app.packageName)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
|
||||
child: Column(
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 14, color: p.ink)),
|
||||
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),
|
||||
),
|
||||
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/widgets/account_picker_sheet.dart';
|
||||
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
||||
import '../../application/ai_providers.dart';
|
||||
import '../../application/inbox_controller.dart';
|
||||
import '../../data/parser/draft_codec.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
@@ -19,6 +20,7 @@ import '../../domain/enums.dart';
|
||||
import '../screens/rule_editor_screen.dart';
|
||||
import 'confidence_badge.dart';
|
||||
import 'gate_check_labels.dart';
|
||||
import 'parse_error_labels.dart';
|
||||
|
||||
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
|
||||
/// действия — «Создать правило», «Подтвердить», «Игнорировать».
|
||||
@@ -51,6 +53,9 @@ class InboxCard extends ConsumerWidget {
|
||||
),
|
||||
child: message.status == RawMessageStatus.failed
|
||||
? _FailedBody(message: message)
|
||||
// pendingAi — ждёт сети/AI: причина + ручной ретрай (draftJson ещё нет).
|
||||
: message.status == RawMessageStatus.pendingAi
|
||||
? _PendingAiBody(message: message)
|
||||
: bundle == null
|
||||
? _UnrecognizedBody(message: message)
|
||||
// pairedRawMessageId → склеенная пара «Перевод между счетами».
|
||||
@@ -210,7 +215,7 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
final acc = await _resolveAccount(context,
|
||||
userId: userId, accountId: accountId);
|
||||
if (acc == null || !context.mounted) return;
|
||||
await _runReporting(
|
||||
await _runLearning(
|
||||
context,
|
||||
() => ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
@@ -256,7 +261,7 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
final acc =
|
||||
await _resolveAccount(context, userId: userId, accountId: accountId);
|
||||
if (acc == null || !context.mounted) return;
|
||||
await _runReporting(
|
||||
await _runLearning(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||
userId: userId,
|
||||
@@ -292,7 +297,7 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
final acc = await _resolveAccount(context,
|
||||
userId: userId, accountId: result.accountId ?? accountId);
|
||||
if (acc == null || !context.mounted) return;
|
||||
await _runReporting(
|
||||
await _runLearning(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||
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/дефолт), иначе —
|
||||
/// пикер счёта. `null` из пикера = пользователь отменил.
|
||||
Future<String?> _resolveAccount(
|
||||
@@ -549,7 +575,7 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
||||
accountId: draft.accountId ?? widget.defaultAccountId,
|
||||
);
|
||||
if (acc == null || !mounted) return;
|
||||
await _runReporting(
|
||||
await _runLearning(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
||||
userId: widget.userId,
|
||||
@@ -929,7 +955,7 @@ class _FailedBody extends ConsumerWidget {
|
||||
),
|
||||
if (message.lastParseError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(message.lastParseError!,
|
||||
Text(parseErrorLabel(context, message.lastParseError!),
|
||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
||||
maxLines: 3,
|
||||
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 — сегмент неактивен и приглушён), карандаш всегда активен
|
||||
/// и открывает редактор/форму. Используется для «Создать правило» и
|
||||
|
||||
@@ -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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -115,20 +116,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
// Demo-seeding is dev-only tooling: hidden in release builds so it
|
||||
// can't be tapped by accident in the production app.
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
Text(
|
||||
l10n.profileHint,
|
||||
|
||||
@@ -453,6 +453,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
graphic:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: graphic
|
||||
sha256: f0028af737f7fdd5fd50c043af85242d72638ae040a820470208bc885a229d04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -685,6 +693,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+8
-1
@@ -41,6 +41,7 @@ dependencies:
|
||||
|
||||
# Charts
|
||||
fl_chart: ^1.2.0
|
||||
graphic: ^2.7.0
|
||||
|
||||
# AI fallback (DeepSeek)
|
||||
http: ^1.2.2
|
||||
@@ -70,8 +71,14 @@ flutter:
|
||||
|
||||
# App launcher icons — regenerate with:
|
||||
# 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:
|
||||
android: true
|
||||
ios: false
|
||||
min_sdk_android: 21
|
||||
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;
|
||||
});
|
||||
|
||||
testWidgets('по умолчанию: оба «All» активны, чип «Necessary» виден',
|
||||
testWidgets('по умолчанию: оба «All» активны, чип «Required» виден',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pump(); // activeUserControllerProvider разрешается
|
||||
@@ -92,10 +92,10 @@ void main() {
|
||||
// «All» в обеих строках фильтров.
|
||||
expect(find.text('All'), findsNWidgets(2));
|
||||
// Чип обязательности + пилюля 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.pump();
|
||||
|
||||
@@ -103,21 +103,21 @@ void main() {
|
||||
// AnimatedSize — доигрываем анимацию.
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Чип скрыт, tx1 отфильтрована — «Necessary» нет нигде.
|
||||
expect(find.text('Necessary'), findsNothing);
|
||||
// Чип скрыт, tx1 отфильтрована — «Required» нет нигде.
|
||||
expect(find.text('Required'), findsNothing);
|
||||
|
||||
// Возврат на «All» импульсивности возвращает чип.
|
||||
await tester.tap(find.text('All').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Necessary'), findsNWidgets(2));
|
||||
expect(find.text('Required'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('выбор «Impulse» сбрасывает выбранную «Necessary» (каскад)',
|
||||
testWidgets('выбор «Impulse» сбрасывает выбранную «Required» (каскад)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Necessary').first);
|
||||
await tester.tap(find.text('Required').first);
|
||||
await tester.pumpAndSettle();
|
||||
final container = _container(tester);
|
||||
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,
|
||||
)));
|
||||
|
||||
expect(find.text('Necessary'), findsOneWidget);
|
||||
expect(find.text('Required'), findsOneWidget);
|
||||
expect(find.text('Impulse'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bolt), findsOneWidget);
|
||||
expect(find.text('not marked'), findsNothing);
|
||||
|
||||
@@ -69,7 +69,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(find.text('комментарий'), findsNothing);
|
||||
expect(find.text('Necessary'), findsOneWidget);
|
||||
expect(find.text('Required'), findsOneWidget);
|
||||
});
|
||||
|
||||
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/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/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_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/source_app.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/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/rule_candidates_repository.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, RawMessageStatus)> statusUpdates = [];
|
||||
final List<Map<String, Object?>> afterParse = [];
|
||||
final List<String> retried = [];
|
||||
|
||||
@override
|
||||
Future<void> resetForRetry(String id) async {
|
||||
retried.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> linkTransaction(String id, String transactionId) async {
|
||||
@@ -214,40 +220,23 @@ class _FakeRuleCandidatesRepo implements RuleCandidatesRepository {
|
||||
throw UnimplementedError(invocation.memberName.toString());
|
||||
}
|
||||
|
||||
class _FakeAccountBindingsRepo implements AccountBindingsRepository {
|
||||
AccountBinding? existing;
|
||||
final List<Map<String, Object?>> created = [];
|
||||
class _FakeSourceAppsRepo implements SourceAppsRepository {
|
||||
/// Приложение-источник сообщения (null — не добавлено в allowlist).
|
||||
SourceApp? app;
|
||||
|
||||
final List<(String, String?)> defaultsSet = [];
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByPackageAndCard(
|
||||
Future<SourceApp?> findByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
String cardLast4,
|
||||
) async =>
|
||||
existing;
|
||||
app?.packageName == packageName ? app : null;
|
||||
|
||||
@override
|
||||
Future<AccountBinding> create({
|
||||
required String userId,
|
||||
String? packageName,
|
||||
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,
|
||||
);
|
||||
Future<void> setDefaultAccount(String id, String? accountId) async {
|
||||
defaultsSet.add((id, accountId));
|
||||
if (app?.id == id) app = app!.copyWith(defaultAccountId: accountId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -255,12 +244,20 @@ class _FakeAccountBindingsRepo implements AccountBindingsRepository {
|
||||
throw UnimplementedError(invocation.memberName.toString());
|
||||
}
|
||||
|
||||
SourceApp _sourceApp({String? defaultAccountId}) => SourceApp(
|
||||
id: 'app1',
|
||||
userId: _userId,
|
||||
packageName: 'ru.sberbankmobile',
|
||||
defaultAccountId: defaultAccountId,
|
||||
createdAt: _now,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late _FakeTransactionRepo txRepo;
|
||||
late _FakeRawMessagesRepo rawRepo;
|
||||
late _FakeParseRulesRepo rulesRepo;
|
||||
late _FakeRuleCandidatesRepo candidatesRepo;
|
||||
late _FakeAccountBindingsRepo bindingsRepo;
|
||||
late _FakeSourceAppsRepo sourceAppsRepo;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
@@ -268,14 +265,14 @@ void main() {
|
||||
rawRepo = _FakeRawMessagesRepo();
|
||||
rulesRepo = _FakeParseRulesRepo();
|
||||
candidatesRepo = _FakeRuleCandidatesRepo();
|
||||
bindingsRepo = _FakeAccountBindingsRepo();
|
||||
sourceAppsRepo = _FakeSourceAppsRepo()..app = _sourceApp();
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
transactionRepositoryProvider.overrideWithValue(txRepo),
|
||||
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
|
||||
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
|
||||
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
|
||||
accountBindingsRepositoryProvider.overrideWithValue(bindingsRepo),
|
||||
sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -286,8 +283,9 @@ void main() {
|
||||
container.read(inboxControllerProvider.notifier);
|
||||
|
||||
group('createRule', () {
|
||||
test('creates transaction + rule, removes candidate, links, binds', () async {
|
||||
await controller().createRule(
|
||||
test('creates transaction + rule, removes candidate, links, learns default',
|
||||
() async {
|
||||
final learned = await controller().createRule(
|
||||
userId: _userId,
|
||||
message: _message(),
|
||||
draft: _draft(),
|
||||
@@ -312,41 +310,17 @@ void main() {
|
||||
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
|
||||
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
||||
|
||||
// _maybeBind: no existing binding → creates one (card *3456 → acc1).
|
||||
expect(bindingsRepo.created, hasLength(1));
|
||||
expect(bindingsRepo.created.single['cardLast4'], '3456');
|
||||
expect(bindingsRepo.created.single['accountId'], 'acc1');
|
||||
// Авто-обучение: дефолта не было → счёт записан, вернулось true.
|
||||
expect(learned, isTrue);
|
||||
expect(sourceAppsRepo.defaultsSet, [('app1', 'acc1')]);
|
||||
|
||||
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', () {
|
||||
test('creates transaction without a rule, observes candidate', () async {
|
||||
await controller().confirmOnce(
|
||||
final learned = await controller().confirmOnce(
|
||||
userId: _userId,
|
||||
message: _message(),
|
||||
draft: _draft(),
|
||||
@@ -359,6 +333,50 @@ void main() {
|
||||
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
||||
expect(candidatesRepo.observed, hasLength(1));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
'включённый банк без карты + глобальный дефолт + правило категории '
|
||||
'→ авто-применение на дефолтный счёт', () async {
|
||||
'включённый банк + дефолтный счёт приложения + правило категории '
|
||||
'→ авто-применение на счёт приложения', () async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
await _seed(db);
|
||||
container = ProviderContainer(overrides: [
|
||||
@@ -211,16 +211,15 @@ void main() {
|
||||
repo = container.read(rawMessagesRepositoryProvider);
|
||||
await enableAi(container);
|
||||
|
||||
// Allowlist: банк включён.
|
||||
// Allowlist: банк включён, дефолтный счёт приложения задан (trusted).
|
||||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||
id: 'src-1',
|
||||
userId: _userId,
|
||||
packageName: _bank,
|
||||
));
|
||||
// Глобальный дефолтный счёт.
|
||||
await container
|
||||
.read(accountRepositoryProvider)
|
||||
.setDefault(_accountId, _userId);
|
||||
.read(sourceAppsRepositoryProvider)
|
||||
.setDefaultAccount('src-1', _accountId);
|
||||
// Категория + правило merchant→category на «LENTA».
|
||||
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
|
||||
id: 'cat-1',
|
||||
@@ -237,7 +236,7 @@ void main() {
|
||||
categoryId: 'cat-1',
|
||||
);
|
||||
// Gate-чек-лист: правило есть, сумма 1500 находится в теле, валюта RUB,
|
||||
// тип совпадает с правилом, счёт — глобальный дефолт (trusted).
|
||||
// тип совпадает с правилом, счёт — дефолт приложения (trusted).
|
||||
|
||||
_activateWorker(container);
|
||||
|
||||
@@ -252,13 +251,66 @@ void main() {
|
||||
expect(msg.status, RawMessageStatus.applied,
|
||||
reason: 'lastParseError=${msg.lastParseError}');
|
||||
|
||||
// Создалась транзакция на дефолтном счёте.
|
||||
// Создалась транзакция на дефолтном счёте приложения.
|
||||
final txns = await db.select(db.transactionsTable).get();
|
||||
expect(txns, hasLength(1));
|
||||
expect(txns.first.accountId, _accountId);
|
||||
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 обновляется без перезапуска: добавили банк → парсится',
|
||||
() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
|
||||
@@ -141,12 +141,11 @@ void main() {
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
await settings.setAiConsent(true);
|
||||
// Однозначные привязки: bankA → accA, bankB → accB (resolver #3, trusted).
|
||||
final bindings = container.read(accountBindingsRepositoryProvider);
|
||||
await bindings.create(
|
||||
userId: _userId, packageName: _bankA, accountId: _accA);
|
||||
await bindings.create(
|
||||
userId: _userId, packageName: _bankB, accountId: _accB);
|
||||
// Дефолтные счета приложений: bankA → accA, bankB → accB (resolver #2,
|
||||
// trusted).
|
||||
final sourceApps = container.read(sourceAppsRepositoryProvider);
|
||||
await sourceApps.setDefaultAccount('src-a', _accA);
|
||||
await sourceApps.setDefaultAccount('src-b', _accB);
|
||||
}
|
||||
|
||||
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: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/enums.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart';
|
||||
|
||||
const _userId = 'u1';
|
||||
const _pkg = 'ru.sberbankmobile';
|
||||
final _now = DateTime(2026, 1, 1);
|
||||
|
||||
AccountBinding _binding({
|
||||
required String id,
|
||||
required String accountId,
|
||||
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',
|
||||
ParseRule _senderRule(String pattern, String accountId, {bool enabled = true}) =>
|
||||
ParseRule(
|
||||
id: 'sr-$pattern',
|
||||
userId: _userId,
|
||||
kind: ParseRuleKind.senderToAccount,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: pattern,
|
||||
accountId: accountId,
|
||||
enabled: enabled,
|
||||
createdAt: _now,
|
||||
);
|
||||
|
||||
/// Конфигурируемый фейк: возвращает заданные привязки из in-memory списка.
|
||||
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,
|
||||
AccountResolution _resolve({
|
||||
List<ParseRule> senderRules = const [],
|
||||
String? appDefaultAccountId,
|
||||
String? globalDefaultAccountId,
|
||||
String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
|
||||
}) {
|
||||
final resolver = AccountResolver(_FakeBindingsRepo(bindings));
|
||||
return resolver.resolve(
|
||||
userId: _userId,
|
||||
packageName: _pkg,
|
||||
body: body,
|
||||
cardLast4: cardLast4,
|
||||
phone: phone,
|
||||
senderRules: senderRules,
|
||||
globalDefaultAccountId: globalDefaultAccountId,
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
resolveAccount(
|
||||
body: body,
|
||||
senderRules: senderRules,
|
||||
appDefaultAccountId: appDefaultAccountId,
|
||||
globalDefaultAccountId: globalDefaultAccountId,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('AccountResolver.resolve (table B)', () {
|
||||
test('#1 binding by package + card → 100, trusted', () async {
|
||||
final r = await _resolve(
|
||||
[_binding(id: 'b', accountId: 'acc-card', cardLast4: '3456')],
|
||||
cardLast4: '3456',
|
||||
);
|
||||
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')],
|
||||
group('resolveAccount (лестница §B)', () {
|
||||
test('#1 senderToAccount rule → 90, trusted; бьёт оба дефолта', () {
|
||||
final r = _resolve(
|
||||
senderRules: [_senderRule('*3456', 'acc-rule')],
|
||||
appDefaultAccountId: 'acc-app',
|
||||
globalDefaultAccountId: 'acc-global',
|
||||
);
|
||||
expect(r.accountId, 'acc-rule');
|
||||
expect(r.score, 90);
|
||||
@@ -138,46 +45,39 @@ void main() {
|
||||
expect(r.source, AccountSource.senderRule);
|
||||
});
|
||||
|
||||
test('#3 single binding by package → 75, trusted', () async {
|
||||
final r = await _resolve([_binding(id: 'b', accountId: 'acc-single')]);
|
||||
expect(r.accountId, 'acc-single');
|
||||
expect(r.score, 75);
|
||||
expect(r.trusted, isTrue);
|
||||
expect(r.source, AccountSource.singleBinding);
|
||||
test('#1 несовпавшее/выключенное правило пропускается', () {
|
||||
final r = _resolve(
|
||||
senderRules: [
|
||||
_senderRule('НЕ СОВПАДЁТ', 'acc-miss'),
|
||||
_senderRule('*3456', 'acc-off', enabled: false),
|
||||
],
|
||||
appDefaultAccountId: 'acc-app',
|
||||
);
|
||||
expect(r.accountId, 'acc-app');
|
||||
expect(r.source, AccountSource.appDefault);
|
||||
});
|
||||
|
||||
test('#4 per-app default binding → 70, trusted', () async {
|
||||
final r = await _resolve([
|
||||
_binding(id: 'b1', accountId: 'a1'),
|
||||
_binding(id: 'b2', accountId: 'acc-default', isDefault: true),
|
||||
]);
|
||||
expect(r.accountId, 'acc-default');
|
||||
expect(r.score, 70);
|
||||
test('#2 дефолт приложения → 75, trusted', () {
|
||||
final r = _resolve(
|
||||
appDefaultAccountId: 'acc-app',
|
||||
globalDefaultAccountId: 'acc-global',
|
||||
);
|
||||
expect(r.accountId, 'acc-app');
|
||||
expect(r.score, 75);
|
||||
expect(r.trusted, isTrue);
|
||||
expect(r.source, AccountSource.appDefault);
|
||||
});
|
||||
|
||||
test('#5 multiple bindings, no default → 45, NOT trusted', () async {
|
||||
final r = await _resolve([
|
||||
_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');
|
||||
test('#3 глобальный дефолт → 40, NOT trusted (смена поведения)', () {
|
||||
final r = _resolve(globalDefaultAccountId: 'acc-global');
|
||||
expect(r.accountId, 'acc-global');
|
||||
expect(r.score, 40);
|
||||
expect(r.trusted, isTrue);
|
||||
expect(r.trusted, isFalse);
|
||||
expect(r.source, AccountSource.globalDefault);
|
||||
});
|
||||
|
||||
test('#7 nothing → null, 15, NOT trusted', () async {
|
||||
final r = await _resolve(const []);
|
||||
test('#4 ничего → null, 15, NOT trusted', () {
|
||||
final r = _resolve();
|
||||
expect(r.accountId, isNull);
|
||||
expect(r.score, 15);
|
||||
expect(r.trusted, isFalse);
|
||||
|
||||
@@ -49,16 +49,16 @@ ParseDraft _draft({
|
||||
|
||||
const _trusted = AccountResolution(
|
||||
accountId: 'a1',
|
||||
score: 100,
|
||||
score: 90,
|
||||
trusted: true,
|
||||
source: AccountSource.bindingCard,
|
||||
source: AccountSource.senderRule,
|
||||
);
|
||||
|
||||
const _untrusted = AccountResolution(
|
||||
accountId: 'a1',
|
||||
score: 45,
|
||||
score: 40,
|
||||
trusted: false,
|
||||
source: AccountSource.ambiguous,
|
||||
source: AccountSource.globalDefault,
|
||||
);
|
||||
|
||||
void main() {
|
||||
@@ -166,7 +166,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('untrusted account (ambiguous multi-binding) → inbox', () {
|
||||
test('untrusted account (global default) → inbox', () {
|
||||
final r = decide(
|
||||
autoApplyEnabled: true,
|
||||
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/domain/entities/account.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/data/parser/draft_codec.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 confirmOnceCalls = 0;
|
||||
int ignoreCalls = 0;
|
||||
int retryCalls = 0;
|
||||
String? lastCreateRuleCategory;
|
||||
String? lastCreateRuleAccount;
|
||||
String? lastConfirmCategory;
|
||||
@@ -34,8 +36,12 @@ class FakeInboxController extends InboxController {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
|
||||
/// Что возвращать из createRule/confirmOnce: true = «дефолт приложения
|
||||
/// только что выучен» → карточка показывает SnackBar.
|
||||
bool learnResult = false;
|
||||
|
||||
@override
|
||||
Future<void> createRule({
|
||||
Future<bool> createRule({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required ParseDraft draft,
|
||||
@@ -44,25 +50,27 @@ class FakeInboxController extends InboxController {
|
||||
required String merchantCanonical,
|
||||
required String pattern,
|
||||
MatchMode matchMode = MatchMode.contains,
|
||||
bool bindAccount = true,
|
||||
bool learnAppDefault = true,
|
||||
}) async {
|
||||
createRuleCalls++;
|
||||
lastCreateRuleCategory = categoryId;
|
||||
lastCreateRuleAccount = accountId;
|
||||
return learnResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> confirmOnce({
|
||||
Future<bool> confirmOnce({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required ParseDraft draft,
|
||||
required String accountId,
|
||||
String? categoryId,
|
||||
bool bindAccount = true,
|
||||
bool learnAppDefault = true,
|
||||
}) async {
|
||||
confirmOnceCalls++;
|
||||
lastConfirmCategory = categoryId;
|
||||
lastConfirmAccount = accountId;
|
||||
return learnResult;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -74,8 +82,28 @@ class FakeInboxController extends InboxController {
|
||||
Future<void> ignore(RawMessage message) async {
|
||||
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() {
|
||||
final draft = ParseDraft(
|
||||
rawMessageId: 'msg1',
|
||||
@@ -232,6 +260,37 @@ void main() {
|
||||
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 {
|
||||
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
||||
await tester.pump();
|
||||
@@ -494,4 +553,42 @@ void main() {
|
||||
expect(find.text(l10n.inboxUnrecognized), 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