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:
2026-07-18 00:09:12 +03:00
co-authored by Claude Opus 4.8
parent d8dc6efe0a
commit 4f99b80169
77 changed files with 2427 additions and 1381 deletions
+2 -1
View File
@@ -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)"
]
}
}
+14 -3
View File
@@ -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,
+26 -3
View File
@@ -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"
}
}
}
+3
View File
@@ -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>
+5
View File
@@ -1,5 +1,10 @@
# Workflow определения счёта при парсинге уведомлений
> ⚠️ **Реализовано и затем упрощено (2026-07):** описанная здесь модель привязок
> `account_bindings` (карта/телефон → счёт, per-app default со звёздочкой) удалена в
> schemaVersion 2. Действующая модель: правило `senderToAccount` →
> `source_apps.defaultAccountId` → глобальный дефолт (не trusted). Документ — история.
## Context
Приложение парсит уведомления банков и создаёт черновики транзакций. Проблемы по текущему коду:
+5
View File
@@ -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.
**Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз.** При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя.
+4
View File
@@ -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
View File
@@ -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",
+67 -25
View File
@@ -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.
///
+37 -13
View File
@@ -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';
+34 -11
View File
@@ -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
View File
@@ -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": "Отправитель → счёт",
+6 -11
View File
@@ -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(
+2 -2
View File
@@ -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)}';
}
+93 -5
View File
@@ -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,61 +26,28 @@ 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),
return Scaffold(
backgroundColor: p.paper,
appBar: AppBar(
backgroundColor: p.paper,
title: Text(l10n.analyticsTitle),
actions: const [
MonthStepper(),
SizedBox(width: 12),
],
),
child: _HubTile(
icon: Icons.insights_outlined,
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),
),
),
],
),
);
}
}
class _HubTile extends StatelessWidget {
const _HubTile({
required this.icon,
required this.title,
required this.onTap,
});
final IconData icon;
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
child: Row(
children: [
Icon(icon, size: 20, color: p.ink2),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(fontSize: 14, color: p.ink),
),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
),
@@ -10,13 +10,13 @@ import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../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, шаги 38) для 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,63 +20,25 @@ 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,
/// Разрешает `accountId` по лестнице источников (§B): правило senderToAccount
/// (паттерн в теле → счёт) → дефолтный счёт приложения → глобальный дефолтный
/// счёт (без доверия) → ничего.
AccountResolution resolveAccount({
required String body,
String? cardLast4,
String? phone,
String? merchantRaw,
List<ParseRule> senderRules = const [],
String? appDefaultAccountId,
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-правило (матч по телу).
}) {
// #1 — senderToAccount-правило (матч по телу).
final senderRule =
findSenderRule(senderRules, body: body, merchantRaw: merchantRaw);
if (senderRule?.accountId != null) {
@@ -99,57 +50,32 @@ class AccountResolver {
);
}
final byPkg = await _bindings.findByPackageName(userId, packageName);
// #3 — единственный binding по packageName.
if (byPkg.length == 1) {
// #2 — дефолтный счёт приложения (выбран пользователем / авто-обучен).
if (appDefaultAccountId != null) {
return AccountResolution(
accountId: byPkg.first.accountId,
accountId: appDefaultAccountId,
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.
// #3 — глобальный Account.isDefault: префилл для Inbox, но НЕ trusted —
// авто-применение требует явно подтверждённого счёта приложения.
if (globalDefaultAccountId != null) {
return AccountResolution(
accountId: globalDefaultAccountId,
score: 40,
trusted: true,
trusted: false,
source: AccountSource.globalDefault,
);
}
// #7 — ничего.
// #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).
@@ -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);
}
@@ -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;
}
@@ -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,
),
@@ -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,
@@ -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),
);
}
}
@@ -330,17 +330,16 @@ 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(
children: [
Row(
child: Row(
children: [
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
const SizedBox(width: 12),
@@ -348,8 +347,7 @@ class _AddedAppTile extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(fontSize: 14, color: p.ink)),
Text(title, style: TextStyle(fontSize: 14, color: p.ink)),
if (app.displayName != null)
Text(app.packageName,
style: TextStyle(fontSize: 11, color: p.ink2)),
@@ -361,45 +359,9 @@ class _AddedAppTile extends ConsumerWidget {
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),
),
),
],
),
),
),
],
),
),
);
}
@@ -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,6 +116,9 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
],
),
),
// 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(
@@ -129,6 +133,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
enabled: userId != null && !_seedingDemo,
),
),
],
const SizedBox(height: 12),
Text(
l10n.profileHint,
+24
View File
@@ -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
View File
@@ -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
+181
View File
@@ -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);
});
});
}
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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,
}) =>
resolveAccount(
body: body,
cardLast4: cardLast4,
phone: phone,
senderRules: senderRules,
appDefaultAccountId: appDefaultAccountId,
globalDefaultAccountId: globalDefaultAccountId,
);
}
void main() {
group('AccountResolver.resolve (table B)', () {
test('#1 binding by package + card100, 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 rule90, 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);
});
}