diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c7231b8..32291a0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 822e544..2eec4f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index df8fa7c..f801af6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fc61e41..746e915 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3939161 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #59C9FD + \ No newline at end of file diff --git a/docs/account_determination_plan.md b/docs/account_determination_plan.md index d3e4ec6..42af65e 100644 --- a/docs/account_determination_plan.md +++ b/docs/account_determination_plan.md @@ -1,5 +1,10 @@ # Workflow определения счёта при парсинге уведомлений +> ⚠️ **Реализовано и затем упрощено (2026-07):** описанная здесь модель привязок +> `account_bindings` (карта/телефон → счёт, per-app default со звёздочкой) удалена в +> schemaVersion 2. Действующая модель: правило `senderToAccount` → +> `source_apps.defaultAccountId` → глобальный дефолт (не trusted). Документ — история. + ## Context Приложение парсит уведомления банков и создаёт черновики транзакций. Проблемы по текущему коду: diff --git a/docs/notification_parsing.md b/docs/notification_parsing.md index e290c29..50606d2 100644 --- a/docs/notification_parsing.md +++ b/docs/notification_parsing.md @@ -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. **Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз.** При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя. diff --git a/docs/notification_parsing_status.md b/docs/notification_parsing_status.md index 2115d1f..ca6f86c 100644 --- a/docs/notification_parsing_status.md +++ b/docs/notification_parsing_status.md @@ -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 ниже — историческое состояние. Легенда: ✅ сделано · 🟡 частично · ❌ не начато. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f78f6fa..6f1c4c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6f41f88..a70bbdb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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. /// diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 34d7194..87fa48e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 90ca3aa..0fd9fc5 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Мерчант → категория'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fce0d84..6a9c58e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Отправитель → счёт", diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index fbf2f41..56d8ecb 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -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( - 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(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(state, const HabitAnalysisScreen()), + builder: (context, state) => const HabitAnalysisScreen(), ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) => AppScaffold( diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart index 5774305..3714f0c 100644 --- a/lib/src/app/router/app_routes.dart +++ b/lib/src/app/router/app_routes.dart @@ -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)}'; } diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index 30db003..f6c3402 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -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 _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>{}; + final global = []; + for (final r in rows) { + final pkg = r.readNullable('package_name'); + if (pkg == null) { + global.add(r); + } else { + byApp.putIfAbsent((r.read('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('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('account_id'), userId, pkg], + ); + } + for (final r in group) { + // Избыточное правило: счёт и так станет дефолтом приложения. + if (def != null && + r.read('account_id') == def.read('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 _bindingToSenderRule(QueryRow r, {String? packageName}) async { + final pattern = + r.readNullable('card_last4') ?? r.readNullable('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('user_id'), + packageName, + // Конвертеры хранят enum по .name. + ParseRuleKind.senderToAccount.name, + MatchMode.contains.name, + pattern, + r.read('match_count'), + r.read('account_id'), + r.read('created_at'), + ], + ); + } + static QueryExecutor _openConnection() { return driftDatabase(name: 'new_budget_db'); } diff --git a/lib/src/features/analytics/domain/month_math.dart b/lib/src/features/analytics/domain/month_math.dart new file mode 100644 index 0000000..651e6c2 --- /dev/null +++ b/lib/src/features/analytics/domain/month_math.dart @@ -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; diff --git a/lib/src/features/analytics/presentation/screens/analytics_screen.dart b/lib/src/features/analytics/presentation/screens/analytics_screen.dart index ef737c9..df5bca8 100644 --- a/lib/src/features/analytics/presentation/screens/analytics_screen.dart +++ b/lib/src/features/analytics/presentation/screens/analytics_screen.dart @@ -5,12 +5,13 @@ import 'package:go_router/go_router.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; -import '../../../../shared/widgets/placeholder_screen.dart'; import '../../../settings/application/settings_controller.dart'; import '../../../user/application/active_user_controller.dart'; +import '../widgets/chart_card.dart'; +import '../widgets/month_stepper.dart'; -/// Хаб аналитики — список подэкранов. Пока единственный пункт — -/// «Анализ привычек» (показывается только при включённом тумблере). +/// Дашборд аналитики — лента карточек-графиков за выбранный месяц. +/// Месяц общий с Home и хабит-экраном ([MonthStepper] в AppBar). class AnalyticsScreen extends ConsumerWidget { const AnalyticsScreen({super.key}); @@ -25,64 +26,31 @@ class AnalyticsScreen extends ConsumerWidget { false) : false; - return PlaceholderScreen( - subtitle: l10n.analyticsSubtitle, - title: l10n.analyticsTitle, - body: !habitEnabled - ? null - : ListView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: [ - Container( - decoration: BoxDecoration( - border: Border.all(color: p.line), - borderRadius: BorderRadius.circular(14), - ), - child: _HubTile( - icon: Icons.insights_outlined, - title: l10n.habitAnalysisTile, - onTap: () => context.push(AppRoutes.habitAnalysis), - ), - ), - ], - ), - ); - } -} - -class _HubTile extends StatelessWidget { - const _HubTile({ - required this.icon, - required this.title, - required this.onTap, - }); - - final IconData icon; - final String title; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final p = context.palette; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), - child: Row( - children: [ - Icon(icon, size: 20, color: p.ink2), - const SizedBox(width: 12), - Expanded( - child: Text( - title, - style: TextStyle(fontSize: 14, color: p.ink), + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(l10n.analyticsTitle), + actions: const [ + MonthStepper(), + SizedBox(width: 12), + ], + ), + body: userId == null + ? const SizedBox.shrink() + : SafeArea( + child: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 24), + children: [ + if (habitEnabled) + ChartCard( + title: l10n.habitAnalysisTile, + leadingIcon: Icons.insights_outlined, + onTap: () => context.push(AppRoutes.habitAnalysis), + ), + ], ), ), - Icon(Icons.chevron_right, size: 18, color: p.ink2), - ], - ), - ), ); } } diff --git a/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart b/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart index b6555bd..81e8ccc 100644 --- a/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart +++ b/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart @@ -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: [ diff --git a/lib/src/features/analytics/presentation/widgets/chart_card.dart b/lib/src/features/analytics/presentation/widgets/chart_card.dart new file mode 100644 index 0000000..5df508a --- /dev/null +++ b/lib/src/features/analytics/presentation/widgets/chart_card.dart @@ -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), + ), + ), + ); + } +} diff --git a/lib/src/features/analytics/presentation/widgets/chart_theme.dart b/lib/src/features/analytics/presentation/widgets/chart_theme.dart new file mode 100644 index 0000000..b24a89a --- /dev/null +++ b/lib/src/features/analytics/presentation/widgets/chart_theme.dart @@ -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 moneyAxis() => AxisGuide( + label: _axisLabel(), + grid: PaintStyle(strokeColor: _p.line, strokeWidth: 0.5), + ); + + /// Ось категорий/времени: линия оси `line` 0.5, подписи 10px `ink2`, + /// без сетки. + AxisGuide labelAxis() => AxisGuide( + 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? 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; +} diff --git a/lib/src/features/analytics/presentation/widgets/month_stepper.dart b/lib/src/features/analytics/presentation/widgets/month_stepper.dart new file mode 100644 index 0000000..a0ddecc --- /dev/null +++ b/lib/src/features/analytics/presentation/widgets/month_stepper.dart @@ -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), + ), + ); + } +} diff --git a/lib/src/features/categories/presentation/screens/categories_list_screen.dart b/lib/src/features/categories/presentation/screens/categories_list_screen.dart index db90019..373ce2f 100644 --- a/lib/src/features/categories/presentation/screens/categories_list_screen.dart +++ b/lib/src/features/categories/presentation/screens/categories_list_screen.dart @@ -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), - ], - ), - ); - } -} diff --git a/lib/src/features/notification_parsing/application/account_bindings_controller.dart b/lib/src/features/notification_parsing/application/account_bindings_controller.dart deleted file mode 100644 index a522793..0000000 --- a/lib/src/features/notification_parsing/application/account_bindings_controller.dart +++ /dev/null @@ -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> accountBindingsList(Ref ref, String userId) => - ref.watch(accountBindingsRepositoryProvider).watchByUser(userId); - -/// CRUD над привязками «карта/телефон → счёт». -@Riverpod(keepAlive: true) -class AccountBindingsController extends _$AccountBindingsController { - @override - AsyncValue build() => const AsyncData(null); - - Future 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 setDefault(String id, String userId, String packageName) => ref - .read(accountBindingsRepositoryProvider) - .setDefault(id, userId, packageName); - - Future delete(String id) => - ref.read(accountBindingsRepositoryProvider).deleteById(id); -} diff --git a/lib/src/features/notification_parsing/application/inbox_controller.dart b/lib/src/features/notification_parsing/application/inbox_controller.dart index 1bf681e..660aa47 100644 --- a/lib/src/features/notification_parsing/application/inbox_controller.dart +++ b/lib/src/features/notification_parsing/application/inbox_controller.dart @@ -65,7 +65,10 @@ class InboxController extends _$InboxController { /// «Создать правило»: транзакция + parse_rule + удаление кандидата. /// Все последующие похожие сообщения auto-apply молча. - Future createRule({ + /// + /// Возвращает true, если выбранный счёт был записан дефолтом приложения + /// (авто-обучение) — карточка показывает SnackBar. + Future 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 confirmOnce({ + /// + /// Возвращает true, если счёт записан дефолтом приложения (см. [createRule]). + Future 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 _maybeBind( + /// Авто-обучение дефолтного счёта приложения: первый Confirm по приложению + /// без дефолта запоминает выбранный счёт — следующие сообщения резолвятся + /// trusted и могут авто-применяться. true = дефолт записан только что. + Future _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; } } diff --git a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart index 980f3c8..b7a7032 100644 --- a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart +++ b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart @@ -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); diff --git a/lib/src/features/notification_parsing/application/parsing_pipeline.dart b/lib/src/features/notification_parsing/application/parsing_pipeline.dart index 7f5b95b..80af1a1 100644 --- a/lib/src/features/notification_parsing/application/parsing_pipeline.dart +++ b/lib/src/features/notification_parsing/application/parsing_pipeline.dart @@ -19,6 +19,7 @@ import '../domain/entities/parse_draft.dart'; import '../domain/entities/raw_message.dart'; import '../domain/entities/rule_candidate.dart'; import '../domain/entities/rule_suggestion.dart'; +import '../domain/entities/source_app.dart'; import '../domain/enums.dart'; import 'ai_providers.dart'; import 'notification_parsing_providers.dart'; @@ -78,8 +79,7 @@ class ParsingPipeline { } // Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored. - await _extractViaAi(userId, msg, settings, - selfMerchant: sourceApp.selfMerchant); + await _extractViaAi(userId, msg, settings, sourceApp: sourceApp); } /// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён), @@ -88,7 +88,7 @@ class ParsingPipeline { String userId, RawMessage msg, ParsingSettings settings, { - required bool selfMerchant, + required SourceApp sourceApp, }) async { final repo = _ref.read(rawMessagesRepositoryProvider); final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier); @@ -165,7 +165,7 @@ class ParsingPipeline { ); case AiParseStatus.draft: await _runPipeline(userId, msg, outcome.draft!, settings, - categories: categories, selfMerchant: selfMerchant); + categories: categories, sourceApp: sourceApp); } } on DeepSeekNetworkException { await repo.updateAfterParse( @@ -194,18 +194,19 @@ class ParsingPipeline { /// Общий хвост pipeline (§5, шаги 3–8) для AI-draft. /// - /// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon): - /// AI-подсказка категории глушится, merchant→category правило не - /// предлагается — категорию пользователь выбирает вручную на карточке. + /// [sourceApp] — приложение-источник: `selfMerchant` («мерчант — само + /// приложение», Ozon: AI-подсказка категории глушится, merchant→category + /// правило не предлагается) и `defaultAccountId` для резолвера счёта. Future _runPipeline( String userId, RawMessage msg, ParseDraft draft0, ParsingSettings settings, { List? 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 ─────────────────────────────────────────────────────── diff --git a/lib/src/features/notification_parsing/application/parsing_worker.dart b/lib/src/features/notification_parsing/application/parsing_worker.dart index bd7d3dd..00f4535 100644 --- a/lib/src/features/notification_parsing/application/parsing_worker.dart +++ b/lib/src/features/notification_parsing/application/parsing_worker.dart @@ -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 _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 _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) { diff --git a/lib/src/features/notification_parsing/application/source_apps_controller.dart b/lib/src/features/notification_parsing/application/source_apps_controller.dart index 6245c91..311ba22 100644 --- a/lib/src/features/notification_parsing/application/source_apps_controller.dart +++ b/lib/src/features/notification_parsing/application/source_apps_controller.dart @@ -41,6 +41,9 @@ class SourceAppsController extends _$SourceAppsController { Future setSelfMerchant(String id, {required bool value}) => ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value); + Future setDefaultAccount(String id, String? accountId) => + ref.read(sourceAppsRepositoryProvider).setDefaultAccount(id, accountId); + Future delete(String id) => ref.read(sourceAppsRepositoryProvider).deleteById(id); } diff --git a/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart deleted file mode 100644 index 08b8ad3..0000000 --- a/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart +++ /dev/null @@ -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 - with _$AccountBindingsDaoMixin { - AccountBindingsDao(super.db); - - // ── Streams ──────────────────────────────────────────────────────────────── - - /// Все привязки пользователя — для экрана настроек парсинга. - Stream> 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 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 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 findByPhone( - String userId, - String phone, - ) => - (select(accountBindingsTable) - ..where((t) => - t.userId.equals(userId) & t.phone.equals(phone)) - ..limit(1)) - .getSingleOrNull(); - - /// Все привязки по packageName (для эвристики «один счёт банка»). - Future> findByPackageName( - String userId, - String packageName, - ) => - (select(accountBindingsTable) - ..where((t) => - t.userId.equals(userId) & t.packageName.equals(packageName))) - .get(); - - /// Умолчательная привязка приложения (card неизвестна) — §B #4. - Future 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 insert(AccountBindingsTableCompanion companion) => - into(accountBindingsTable).insert(companion); - - Future updateRow(AccountBindingsTableCompanion companion) => - (update(accountBindingsTable) - ..where((t) => t.id.equals(companion.id.value))) - .write(companion); - - /// Инкремент matchCount при каждом успешном матче. - Future incrementMatchCount(String id) => customUpdate( - 'UPDATE account_bindings SET match_count = match_count + 1 WHERE id = ?', - variables: [Variable(id)], - updates: {accountBindingsTable}, - ); - - /// Делает привязку [id] умолчательной для её пакета: снимает флаг у всех - /// прочих привязок этого packageName и выставляет у выбранной (атомарно). - Future 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 deleteById(String id) => - (delete(accountBindingsTable)..where((t) => t.id.equals(id))).go(); -} diff --git a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart index b2154b8..e3f0dc6 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart @@ -33,15 +33,17 @@ class RawMessagesDao extends DatabaseAccessor .watch(); /// Поток сообщений, ожидающих внимания в Inbox: распознанные (`inbox`), - /// неполные (`parsed_partial`) и ошибочные (`failed`) — последние две для - /// AI-ветки (§7) показываются с сырым текстом и кнопкой «попробовать снова». + /// неполные (`parsed_partial`), ошибочные (`failed`) и ждущие сети + /// (`pending_ai`) — последние три для AI-ветки (§7) показываются с сырым + /// текстом и кнопкой «попробовать снова». Stream> 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 ..limit(limit)) .watch(); - /// Реактивный счётчик для бэджа на Home (inbox + parsed_partial + failed). + /// Реактивный счётчик для бэджа на Home. Список статусов должен совпадать + /// с [watchInbox] (inbox + parsed_partial + failed + pending_ai). Stream 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(userId)], readsFrom: {rawMessagesTable}, ); diff --git a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart index d4b24d9..ac732f5 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart @@ -48,6 +48,11 @@ class SourceAppsDao extends DatabaseAccessor (update(sourceAppsTable)..where((t) => t.id.equals(id))) .write(SourceAppsTableCompanion(selfMerchant: Value(value))); + /// Основной счёт приложения; null — сброс. + Future setDefaultAccount(String id, String? accountId) => + (update(sourceAppsTable)..where((t) => t.id.equals(id))) + .write(SourceAppsTableCompanion(defaultAccountId: Value(accountId))); + Future deleteById(String id) => (delete(sourceAppsTable)..where((t) => t.id.equals(id))).go(); } diff --git a/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart b/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart deleted file mode 100644 index c0b6150..0000000 --- a/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart +++ /dev/null @@ -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 get primaryKey => {id}; - - @override - List> get uniqueKeys => [ - {userId, packageName, cardLast4}, - ]; -} diff --git a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart index ae22c89..54ec87f 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart @@ -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 diff --git a/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart b/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart index e26d1c5..6a576be 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart @@ -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 diff --git a/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart b/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart deleted file mode 100644 index c189075..0000000 --- a/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart +++ /dev/null @@ -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, - ); -} diff --git a/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart b/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart index 4e6c5a3..403f6d7 100644 --- a/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart @@ -10,6 +10,7 @@ extension SourceAppMapper on SourceAppsTableData { displayName: displayName, enabled: enabled, selfMerchant: selfMerchant, + defaultAccountId: defaultAccountId, createdAt: createdAt, ); } diff --git a/lib/src/features/notification_parsing/data/parser/account_resolver.dart b/lib/src/features/notification_parsing/data/parser/account_resolver.dart index 57c1770..c4f60f1 100644 --- a/lib/src/features/notification_parsing/data/parser/account_resolver.dart +++ b/lib/src/features/notification_parsing/data/parser/account_resolver.dart @@ -1,18 +1,8 @@ import '../../domain/entities/parse_rule.dart'; -import '../../domain/repositories/account_bindings_repository.dart'; import 'rule_lookup.dart'; /// Откуда взят разрешённый счёт (для диагностики / подсветки). -enum AccountSource { - bindingCard, - bindingPhone, - senderRule, - singleBinding, - appDefault, - ambiguous, - globalDefault, - none, -} +enum AccountSource { senderRule, appDefault, globalDefault, none } /// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B). class AccountResolution { @@ -21,7 +11,6 @@ class AccountResolution { required this.score, required this.trusted, required this.source, - this.bindingId, }); /// Разрешённый счёт (null, если ничего не нашлось). @@ -31,125 +20,62 @@ class AccountResolution { final int score; /// Можно ли доверять счёту для авто-применения. Именно это поле (а не score) - /// решает судьбу в gate: неоднозначность (#5) → false → Inbox; осознанные - /// дефолты (#4, #6) → true → авто-применение разрешено. + /// решает судьбу в gate: правило и дефолт приложения — осознанный выбор + /// пользователя → true; глобальный дефолт — лишь догадка → false → Inbox + /// с префиллом (первый Confirm выучит его как дефолт приложения). final bool trusted; final AccountSource source; - - /// id сработавшей привязки — для инкремента matchCount. - final String? bindingId; } -/// Разрешает `accountId` по цепочке источников (§B): привязки карта/телефон, -/// `senderToAccount`-правила, per-app default и глобальный дефолт счёта. -class AccountResolver { - const AccountResolver(this._bindings); - - final AccountBindingsRepository _bindings; - - Future resolve({ - required String userId, - required String packageName, - required String body, - String? cardLast4, - String? phone, - String? merchantRaw, - List senderRules = const [], - String? globalDefaultAccountId, - }) async { - // #1 — binding по packageName + cardLast4. - if (cardLast4 != null) { - final b = - await _bindings.findByPackageAndCard(userId, packageName, cardLast4); - if (b != null) { - return AccountResolution( - accountId: b.accountId, - score: 100, - trusted: true, - source: AccountSource.bindingCard, - bindingId: b.id, - ); - } - } - - // #1 — binding по телефону (СБП). - if (phone != null) { - final b = await _bindings.findByPhone(userId, phone); - if (b != null) { - return AccountResolution( - accountId: b.accountId, - score: 100, - trusted: true, - source: AccountSource.bindingPhone, - bindingId: b.id, - ); - } - } - - // #2 — senderToAccount-правило (матч по телу). - final senderRule = - findSenderRule(senderRules, body: body, merchantRaw: merchantRaw); - if (senderRule?.accountId != null) { - return AccountResolution( - accountId: senderRule!.accountId, - score: 90, - trusted: true, - source: AccountSource.senderRule, - ); - } - - final byPkg = await _bindings.findByPackageName(userId, packageName); - - // #3 — единственный binding по packageName. - if (byPkg.length == 1) { - return AccountResolution( - accountId: byPkg.first.accountId, - score: 75, - trusted: true, - source: AccountSource.singleBinding, - bindingId: byPkg.first.id, - ); - } - - if (byPkg.length > 1) { - // #4 — per-app default binding (card неизвестна). - final def = await _bindings.findDefaultByPackageName(userId, packageName); - if (def != null) { - return AccountResolution( - accountId: def.accountId, - score: 70, - trusted: true, - source: AccountSource.appDefault, - bindingId: def.id, - ); - } - // #5 — несколько bindings без default-флага → «первый», но не доверяем. - return AccountResolution( - accountId: byPkg.first.accountId, - score: 45, - trusted: false, - source: AccountSource.ambiguous, - bindingId: byPkg.first.id, - ); - } - - // #6 — глобальный Account.isDefault. - if (globalDefaultAccountId != null) { - return AccountResolution( - accountId: globalDefaultAccountId, - score: 40, - trusted: true, - source: AccountSource.globalDefault, - ); - } - - // #7 — ничего. - return const AccountResolution( - accountId: null, - score: 15, - trusted: false, - source: AccountSource.none, +/// Разрешает `accountId` по лестнице источников (§B): правило senderToAccount +/// (паттерн в теле → счёт) → дефолтный счёт приложения → глобальный дефолтный +/// счёт (без доверия) → ничего. +AccountResolution resolveAccount({ + required String body, + String? merchantRaw, + List senderRules = const [], + String? appDefaultAccountId, + String? globalDefaultAccountId, +}) { + // #1 — senderToAccount-правило (матч по телу). + final senderRule = + findSenderRule(senderRules, body: body, merchantRaw: merchantRaw); + if (senderRule?.accountId != null) { + return AccountResolution( + accountId: senderRule!.accountId, + score: 90, + trusted: true, + source: AccountSource.senderRule, ); } + + // #2 — дефолтный счёт приложения (выбран пользователем / авто-обучен). + if (appDefaultAccountId != null) { + return AccountResolution( + accountId: appDefaultAccountId, + score: 75, + trusted: true, + source: AccountSource.appDefault, + ); + } + + // #3 — глобальный Account.isDefault: префилл для Inbox, но НЕ trusted — + // авто-применение требует явно подтверждённого счёта приложения. + if (globalDefaultAccountId != null) { + return AccountResolution( + accountId: globalDefaultAccountId, + score: 40, + trusted: false, + source: AccountSource.globalDefault, + ); + } + + // #4 — ничего. + return const AccountResolution( + accountId: null, + score: 15, + trusted: false, + source: AccountSource.none, + ); } diff --git a/lib/src/features/notification_parsing/data/parser/decision_gate.dart b/lib/src/features/notification_parsing/data/parser/decision_gate.dart index 3a49d47..279e29b 100644 --- a/lib/src/features/notification_parsing/data/parser/decision_gate.dart +++ b/lib/src/features/notification_parsing/data/parser/decision_gate.dart @@ -24,10 +24,11 @@ enum AutoApplyCheck { /// (null у легаси-правил — проверка пропускается). typeMatchesRule, - /// Счёт разрешён (binding / правило / дефолт). + /// Счёт разрешён (правило / дефолт приложения / глобальный дефолт). accountResolved, - /// Счёту можно доверять для авто-применения (не ambiguous multi-binding). + /// Счёту можно доверять для авто-применения: правило senderToAccount или + /// дефолт приложения; глобальный дефолт — лишь префилл, не доверяем. accountTrusted, /// Сумма ниже потолка — защита от галлюцинаций (§15). diff --git a/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart deleted file mode 100644 index fcdba1b..0000000 --- a/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart +++ /dev/null @@ -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> watchByUser(String userId) => _dao - .watchByUser(userId) - .map((rows) => rows.map((r) => r.toDomain()).toList()); - - @override - Future findByPackageAndCard( - String userId, - String packageName, - String cardLast4, - ) async => - (await _dao.findByPackageAndCard(userId, packageName, cardLast4)) - ?.toDomain(); - - @override - Future findByBankKeyAndCard( - String userId, - String bankKey, - String cardLast4, - ) async => - (await _dao.findByBankKeyAndCard(userId, bankKey, cardLast4))?.toDomain(); - - @override - Future findByPhone(String userId, String phone) async => - (await _dao.findByPhone(userId, phone))?.toDomain(); - - @override - Future> findByPackageName( - String userId, - String packageName, - ) async => - (await _dao.findByPackageName(userId, packageName)) - .map((r) => r.toDomain()) - .toList(); - - @override - Future findDefaultByPackageName( - String userId, - String packageName, - ) async => - (await _dao.findDefaultByPackageName(userId, packageName))?.toDomain(); - - @override - Future 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 incrementMatchCount(String id) => _dao.incrementMatchCount(id); - - @override - Future setDefault(String id, String userId, String packageName) => - _dao.setDefault(id, userId, packageName); - - @override - Future deleteById(String id) => _dao.deleteById(id); -} diff --git a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart index 0f035b1..76aca03 100644 --- a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart @@ -54,6 +54,10 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository { Future setSelfMerchant(String id, {required bool value}) => _dao.setSelfMerchant(id, value: value); + @override + Future setDefaultAccount(String id, String? accountId) => + _dao.setDefaultAccount(id, accountId); + @override Future deleteById(String id) => _dao.deleteById(id); } diff --git a/lib/src/features/notification_parsing/domain/entities/account_binding.dart b/lib/src/features/notification_parsing/domain/entities/account_binding.dart deleted file mode 100644 index bf251c9..0000000 --- a/lib/src/features/notification_parsing/domain/entities/account_binding.dart +++ /dev/null @@ -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; -} diff --git a/lib/src/features/notification_parsing/domain/entities/parse_draft.dart b/lib/src/features/notification_parsing/domain/entities/parse_draft.dart index ede4fcd..6535923 100644 --- a/lib/src/features/notification_parsing/domain/entities/parse_draft.dart +++ b/lib/src/features/notification_parsing/domain/entities/parse_draft.dart @@ -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). diff --git a/lib/src/features/notification_parsing/domain/entities/source_app.dart b/lib/src/features/notification_parsing/domain/entities/source_app.dart index c86efec..d3c9e23 100644 --- a/lib/src/features/notification_parsing/domain/entities/source_app.dart +++ b/lib/src/features/notification_parsing/domain/entities/source_app.dart @@ -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; } diff --git a/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart b/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart deleted file mode 100644 index 3591021..0000000 --- a/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart +++ /dev/null @@ -1,49 +0,0 @@ -import '../entities/account_binding.dart'; - -/// Доступ к привязкам «карта/телефон → счёт» ([AccountBinding]). -/// -/// Используется на шаге 3 pipeline (account_resolver) для разрешения accountId. -abstract interface class AccountBindingsRepository { - Stream> watchByUser(String userId); - - Future findByPackageAndCard( - String userId, - String packageName, - String cardLast4, - ); - - Future findByBankKeyAndCard( - String userId, - String bankKey, - String cardLast4, - ); - - Future findByPhone(String userId, String phone); - - Future> findByPackageName( - String userId, - String packageName, - ); - - /// Умолчательная привязка приложения (карта не распознана) — §B #4. - Future findDefaultByPackageName( - String userId, - String packageName, - ); - - Future create({ - required String userId, - String? packageName, - String? bankKey, - String? cardLast4, - String? phone, - required String accountId, - }); - - Future incrementMatchCount(String id); - - /// Делает привязку умолчательной для её пакета (сбрасывает флаг у прочих). - Future setDefault(String id, String userId, String packageName); - - Future deleteById(String id); -} diff --git a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart index bac49be..e6bc6ac 100644 --- a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart @@ -26,5 +26,8 @@ abstract interface class SourceAppsRepository { /// Флаг «мерчант — само приложение» (Ozon, маркетплейсы). Future setSelfMerchant(String id, {required bool value}); + /// Основной счёт приложения (trusted-дефолт резолвера); null — сброс. + Future setDefaultAccount(String id, String? accountId); + Future deleteById(String id); } diff --git a/lib/src/features/notification_parsing/presentation/screens/app_bindings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/app_bindings_screen.dart deleted file mode 100644 index 1a3da2a..0000000 --- a/lib/src/features/notification_parsing/presentation/screens/app_bindings_screen.dart +++ /dev/null @@ -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 []) - .where((b) => b.packageName == packageName) - .toList(); - final accounts = ref.watch(accountsStreamProvider(userId)).value ?? - const []; - 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 _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( - 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, - ); - } - } -} diff --git a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart index 9ef3ccb..695477d 100644 --- a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart @@ -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( diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart index 7bb616a..1dd1dfc 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart @@ -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, ), diff --git a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart index de22f4e..d17e56b 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart @@ -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 { 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 { 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, diff --git a/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart b/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart new file mode 100644 index 0000000..8ebb285 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart @@ -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 []) + .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 []; + // Висячий 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 []) + .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 _addRule( + BuildContext context, + WidgetRef ref, + String userId, + ) async { + final result = await context.push( + 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 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), + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart index fcc75a7..1321522 100644 --- a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart @@ -330,74 +330,36 @@ class _AddedAppTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; - final l10n = context.l10n; final ctrl = ref.read(sourceAppsControllerProvider.notifier); final title = app.displayName ?? app.packageName; + // Одна строка: всё остальное (selfMerchant, дефолтный счёт, правила, + // удаление) — на детальном экране приложения. return InkWell( - onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)), + onTap: () => context.push(AppRoutes.parsingAppDetail(app.packageName)), child: Padding( padding: const EdgeInsets.fromLTRB(14, 8, 8, 8), - child: Column( + child: Row( children: [ - Row( - children: [ - Icon(Icons.apps_outlined, size: 20, color: p.ink2), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: TextStyle(fontSize: 14, color: p.ink)), - if (app.displayName != null) - Text(app.packageName, - style: TextStyle(fontSize: 11, color: p.ink2)), - ], - ), - ), - Switch( - value: app.enabled, - activeThumbColor: p.accent, - onChanged: (v) => ctrl.setEnabled(app.id, enabled: v), - ), - IconButton( - icon: Icon(Icons.delete_outline, size: 20, color: p.ink2), - tooltip: l10n.commonDelete, - onPressed: () => ctrl.delete(app.id), - ), - Icon(Icons.chevron_right, size: 18, color: p.ink2), - ], - ), - // «Мерчант — само приложение» (Ozon): уведомления не называют - // продавца — категория выбирается вручную, правила не предлагаются. - Padding( - padding: const EdgeInsets.only(left: 32), - child: Tooltip( - message: l10n.sourceAppsSelfMerchantHint, - child: Row( - children: [ - Icon(Icons.storefront_outlined, size: 16, color: p.ink2), - const SizedBox(width: 8), - Expanded( - child: Text(l10n.sourceAppsSelfMerchantLabel, - style: TextStyle(fontSize: 12, color: p.ink2)), - ), - SizedBox( - height: 32, - width: 40, - child: Checkbox( - value: app.selfMerchant, - activeColor: p.accent, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onChanged: (v) => - ctrl.setSelfMerchant(app.id, value: v ?? false), - ), - ), - ], - ), + Icon(Icons.apps_outlined, size: 20, color: p.ink2), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 14, color: p.ink)), + if (app.displayName != null) + Text(app.packageName, + style: TextStyle(fontSize: 11, color: p.ink2)), + ], ), ), + Switch( + value: app.enabled, + activeThumbColor: p.accent, + onChanged: (v) => ctrl.setEnabled(app.id, enabled: v), + ), + Icon(Icons.chevron_right, size: 18, color: p.ink2), ], ), ), diff --git a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart index 4f39974..9294096 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart @@ -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 _runReporting( } } +/// То же для confirmOnce/createRule: контроллер возвращает true, когда счёт +/// только что записан дефолтом приложения (авто-обучение) — сообщаем об этом +/// SnackBar'ом, дальше похожие сообщения смогут применяться автоматически. +Future _runLearning( + BuildContext context, + Future 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 _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 — сегмент неактивен и приглушён), карандаш всегда активен /// и открывает редактор/форму. Используется для «Создать правило» и diff --git a/lib/src/features/notification_parsing/presentation/widgets/parse_error_labels.dart b/lib/src/features/notification_parsing/presentation/widgets/parse_error_labels.dart new file mode 100644 index 0000000..325a714 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/widgets/parse_error_labels.dart @@ -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; + } +} diff --git a/lib/src/features/profile/presentation/screens/profile_screen.dart b/lib/src/features/profile/presentation/screens/profile_screen.dart index 502ee1b..5cd6a8c 100644 --- a/lib/src/features/profile/presentation/screens/profile_screen.dart +++ b/lib/src/features/profile/presentation/screens/profile_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -115,20 +116,24 @@ class _ProfileScreenState extends ConsumerState { ], ), ), - const SizedBox(height: 12), - Container( - decoration: BoxDecoration( - border: Border.all(color: p.line), - borderRadius: BorderRadius.circular(14), + // Demo-seeding is dev-only tooling: hidden in release builds so it + // can't be tapped by accident in the production app. + if (kDebugMode) ...[ + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: p.line), + borderRadius: BorderRadius.circular(14), + ), + child: _NavRow( + icon: Icons.auto_fix_high_outlined, + title: l10n.profileSeedDemoTile, + loading: _seedingDemo, + onTap: userId == null ? () {} : () => _seedDemo(userId), + enabled: userId != null && !_seedingDemo, + ), ), - child: _NavRow( - icon: Icons.auto_fix_high_outlined, - title: l10n.profileSeedDemoTile, - loading: _seedingDemo, - onTap: userId == null ? () {} : () => _seedDemo(userId), - enabled: userId != null && !_seedingDemo, - ), - ), + ], const SizedBox(height: 12), Text( l10n.profileHint, diff --git a/pubspec.lock b/pubspec.lock index 7484dbe..c64bfe5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 8adb4bb..751724c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/core/database/migration_v2_test.dart b/test/core/database/migration_v2_test.dart new file mode 100644 index 0000000..b222a75 --- /dev/null +++ b/test/core/database/migration_v2_test.dart @@ -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); + }); +} diff --git a/test/features/analytics/analytics_screen_test.dart b/test/features/analytics/analytics_screen_test.dart new file mode 100644 index 0000000..c515f58 --- /dev/null +++ b/test/features/analytics/analytics_screen_test.dart @@ -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 build() async => + User(id: _userId, name: 'Test', createdAt: DateTime(2024)); +} + +class FakeSettingsController extends SettingsController { + FakeSettingsController({required this.habitEnabled}); + final bool habitEnabled; + + @override + Future 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); + }); +} diff --git a/test/features/analytics/habit_analysis_screen_test.dart b/test/features/analytics/habit_analysis_screen_test.dart index da9aba1..6f8c6d1 100644 --- a/test/features/analytics/habit_analysis_screen_test.dart +++ b/test/features/analytics/habit_analysis_screen_test.dart @@ -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( diff --git a/test/features/analytics/month_math_test.dart b/test/features/analytics/month_math_test.dart new file mode 100644 index 0000000..8c79618 --- /dev/null +++ b/test/features/analytics/month_math_test.dart @@ -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); + }); + }); +} diff --git a/test/features/home/habit_chips_test.dart b/test/features/home/habit_chips_test.dart index 6c20fee..82fc70a 100644 --- a/test/features/home/habit_chips_test.dart +++ b/test/features/home/habit_chips_test.dart @@ -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); diff --git a/test/features/home/tx_row_habit_test.dart b/test/features/home/tx_row_habit_test.dart index 2ea470c..eda9cdb 100644 --- a/test/features/home/tx_row_habit_test.dart +++ b/test/features/home/tx_row_habit_test.dart @@ -69,7 +69,7 @@ void main() { ); expect(find.text('комментарий'), findsNothing); - expect(find.text('Necessary'), findsOneWidget); + expect(find.text('Required'), findsOneWidget); }); testWidgets('флаг включён, оценок нет → "not marked" вместо extraInfo', diff --git a/test/features/notification_parsing/application/inbox_controller_test.dart b/test/features/notification_parsing/application/inbox_controller_test.dart index 5e88f2a..62792b0 100644 --- a/test/features/notification_parsing/application/inbox_controller_test.dart +++ b/test/features/notification_parsing/application/inbox_controller_test.dart @@ -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> afterParse = []; + final List retried = []; + + @override + Future resetForRetry(String id) async { + retried.add(id); + } @override Future 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> created = []; +class _FakeSourceAppsRepo implements SourceAppsRepository { + /// Приложение-источник сообщения (null — не добавлено в allowlist). + SourceApp? app; + + final List<(String, String?)> defaultsSet = []; @override - Future findByPackageAndCard( + Future findByPackageName( String userId, String packageName, - String cardLast4, ) async => - existing; + app?.packageName == packageName ? app : null; @override - Future 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 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); + }); + }); } diff --git a/test/features/notification_parsing/application/parsing_worker_ai_retry_test.dart b/test/features/notification_parsing/application/parsing_worker_ai_retry_test.dart new file mode 100644 index 0000000..9901f82 --- /dev/null +++ b/test/features/notification_parsing/application/parsing_worker_ai_retry_test.dart @@ -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 _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 _enableAi(ProviderContainer c) async { + final settings = c.read(parsingSettingsControllerProvider.notifier); + await c.read(parsingSettingsControllerProvider.future); + await settings.setAiConsent(true); +} + +Future _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 _waitFor( + RawMessagesRepository repo, + String id, + bool Function(RawMessage) predicate, { + Duration timeout = const Duration(seconds: 10), +}) async { + final completer = Completer(); + late final StreamSubscription> 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 _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.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.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.delayed(const Duration(milliseconds: 50)); + + final inserted = await _insert(repo); + + await _waitFor( + repo, + inserted.id, + (m) => m.status == RawMessageStatus.pendingAi, + ); + // Несколько периодов бэкоффа: таймер обязан скипать на явном офлайне. + await Future.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(); + 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.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.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.delayed(const Duration(milliseconds: 300)); + + final msg = await _current(repo, inserted.id); + expect(msg.status, RawMessageStatus.pendingAi); + expect(msg.parseAttemptCount, 0); + expect(aiCalls, 0); + }); +} diff --git a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart index f8324a3..3f5be75 100644 --- a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart +++ b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart @@ -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.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()); diff --git a/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart b/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart index 14c16a6..cfadaf9 100644 --- a/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart +++ b/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart @@ -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 { diff --git a/test/features/notification_parsing/data/raw_messages_inbox_visibility_test.dart b/test/features/notification_parsing/data/raw_messages_inbox_visibility_test.dart new file mode 100644 index 0000000..2ad03ef --- /dev/null +++ b/test/features/notification_parsing/data/raw_messages_inbox_visibility_test.dart @@ -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 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, + }, + ); + }); +} diff --git a/test/features/notification_parsing/parser/account_resolver_test.dart b/test/features/notification_parsing/parser/account_resolver_test.dart index c6dcfdc..f3eb144 100644 --- a/test/features/notification_parsing/parser/account_resolver_test.dart +++ b/test/features/notification_parsing/parser/account_resolver_test.dart @@ -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 bindings; - - @override - Future findByPackageAndCard( - String userId, - String packageName, - String cardLast4, - ) async => - bindings - .where((b) => - b.packageName == packageName && b.cardLast4 == cardLast4) - .firstOrNull; - - @override - Future findByPhone(String userId, String phone) async => - bindings.where((b) => b.phone == phone).firstOrNull; - - @override - Future> findByPackageName( - String userId, - String packageName, - ) async => - bindings.where((b) => b.packageName == packageName).toList(); - - @override - Future 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 _resolve( - List bindings, { - String? cardLast4, - String? phone, +AccountResolution _resolve({ List senderRules = const [], + String? appDefaultAccountId, String? globalDefaultAccountId, String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA', -}) { - final resolver = AccountResolver(_FakeBindingsRepo(bindings)); - return resolver.resolve( - userId: _userId, - packageName: _pkg, - body: body, - cardLast4: cardLast4, - phone: phone, - senderRules: senderRules, - globalDefaultAccountId: globalDefaultAccountId, - ); -} +}) => + resolveAccount( + body: body, + senderRules: senderRules, + appDefaultAccountId: appDefaultAccountId, + globalDefaultAccountId: globalDefaultAccountId, + ); void main() { - group('AccountResolver.resolve (table B)', () { - test('#1 binding by package + card → 100, trusted', () async { - final r = await _resolve( - [_binding(id: 'b', accountId: 'acc-card', cardLast4: '3456')], - cardLast4: '3456', - ); - expect(r.accountId, 'acc-card'); - expect(r.score, 100); - expect(r.trusted, isTrue); - expect(r.source, AccountSource.bindingCard); - }); - - test('#1 binding by phone → 100, trusted', () async { - final r = await _resolve( - [_binding(id: 'b', accountId: 'acc-phone', phone: '+79990001122')], - phone: '+79990001122', - ); - expect(r.accountId, 'acc-phone'); - expect(r.score, 100); - expect(r.trusted, isTrue); - expect(r.source, AccountSource.bindingPhone); - }); - - test('#2 senderToAccount rule → 90, trusted', () async { - // Несколько привязок без default, чтобы single/default не перехватили, - // но правило отправителя имеет приоритет над ними. - final r = await _resolve( - [ - _binding(id: 'b1', accountId: 'a1'), - _binding(id: 'b2', accountId: 'a2'), - ], - senderRules: [_senderRule('PYATEROCHKA', 'acc-rule')], + group('resolveAccount (лестница §B)', () { + test('#1 senderToAccount rule → 90, trusted; бьёт оба дефолта', () { + final r = _resolve( + senderRules: [_senderRule('*3456', 'acc-rule')], + appDefaultAccountId: 'acc-app', + globalDefaultAccountId: 'acc-global', ); expect(r.accountId, 'acc-rule'); expect(r.score, 90); @@ -138,46 +45,39 @@ void main() { expect(r.source, AccountSource.senderRule); }); - test('#3 single binding by package → 75, trusted', () async { - final r = await _resolve([_binding(id: 'b', accountId: 'acc-single')]); - expect(r.accountId, 'acc-single'); - expect(r.score, 75); - expect(r.trusted, isTrue); - expect(r.source, AccountSource.singleBinding); + test('#1 несовпавшее/выключенное правило пропускается', () { + final r = _resolve( + senderRules: [ + _senderRule('НЕ СОВПАДЁТ', 'acc-miss'), + _senderRule('*3456', 'acc-off', enabled: false), + ], + appDefaultAccountId: 'acc-app', + ); + expect(r.accountId, 'acc-app'); + expect(r.source, AccountSource.appDefault); }); - test('#4 per-app default binding → 70, trusted', () async { - final r = await _resolve([ - _binding(id: 'b1', accountId: 'a1'), - _binding(id: 'b2', accountId: 'acc-default', isDefault: true), - ]); - expect(r.accountId, 'acc-default'); - expect(r.score, 70); + test('#2 дефолт приложения → 75, trusted', () { + final r = _resolve( + appDefaultAccountId: 'acc-app', + globalDefaultAccountId: 'acc-global', + ); + expect(r.accountId, 'acc-app'); + expect(r.score, 75); expect(r.trusted, isTrue); expect(r.source, AccountSource.appDefault); }); - test('#5 multiple bindings, no default → 45, NOT trusted', () async { - final r = await _resolve([ - _binding(id: 'b1', accountId: 'a1'), - _binding(id: 'b2', accountId: 'a2'), - ]); - expect(r.accountId, 'a1'); - expect(r.score, 45); - expect(r.trusted, isFalse); - expect(r.source, AccountSource.ambiguous); - }); - - test('#6 global default account → 40, trusted', () async { - final r = await _resolve(const [], globalDefaultAccountId: 'acc-global'); + test('#3 глобальный дефолт → 40, NOT trusted (смена поведения)', () { + final r = _resolve(globalDefaultAccountId: 'acc-global'); expect(r.accountId, 'acc-global'); expect(r.score, 40); - expect(r.trusted, isTrue); + expect(r.trusted, isFalse); expect(r.source, AccountSource.globalDefault); }); - test('#7 nothing → null, 15, NOT trusted', () async { - final r = await _resolve(const []); + test('#4 ничего → null, 15, NOT trusted', () { + final r = _resolve(); expect(r.accountId, isNull); expect(r.score, 15); expect(r.trusted, isFalse); diff --git a/test/features/notification_parsing/parser/decision_gate_test.dart b/test/features/notification_parsing/parser/decision_gate_test.dart index 91f73b7..87ecd24 100644 --- a/test/features/notification_parsing/parser/decision_gate_test.dart +++ b/test/features/notification_parsing/parser/decision_gate_test.dart @@ -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(), diff --git a/test/features/notification_parsing/presentation/inbox_card_test.dart b/test/features/notification_parsing/presentation/inbox_card_test.dart index 8b650c2..5a2acbb 100644 --- a/test/features/notification_parsing/presentation/inbox_card_test.dart +++ b/test/features/notification_parsing/presentation/inbox_card_test.dart @@ -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 build() => const AsyncData(null); + /// Что возвращать из createRule/confirmOnce: true = «дефолт приложения + /// только что выучен» → карточка показывает SnackBar. + bool learnResult = false; + @override - Future createRule({ + Future 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 confirmOnce({ + Future 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 ignore(RawMessage message) async { ignoreCalls++; } + + @override + Future 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); + }); }