diff --git a/.gitignore b/.gitignore index 155587a..037ef9c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,4 @@ fastlane/screenshots/**/*.png fastlane/test_output # Design files (локальные макеты, не нужны в VCS) -design/ + diff --git a/design/flow.jpg b/design/flow.jpg new file mode 100644 index 0000000..697b1b4 Binary files /dev/null and b/design/flow.jpg differ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e6f5f43..c7c4c85 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -199,5 +199,81 @@ "accountValidationNameTooLong": "Name is too long (max 50 chars)", "accountSaveError": "Failed to save the account. Please try again.", "accountArchiveError": "Failed to archive the account. Please try again.", - "accountSetDefaultError": "Account saved, but couldn't mark it as default." + "accountSetDefaultError": "Account saved, but couldn't mark it as default.", + + "profileParsingTile": "Notification parsing", + + "parsingSettingsTitle": "Notification parsing", + "parsingSettingsSubtitle": "Settings", + "parsingEnableLabel": "Recognize notifications", + "parsingStrictnessLabel": "Auto-add strictness", + "parsingStrictnessSoft": "Soft", + "parsingStrictnessNormal": "Normal", + "parsingStrictnessStrict": "Strict", + "parsingStrictnessHint": "The threshold applies to amount, account and type — not to the merchant.", + "parsingRulesTile": "Parsing rules", + "parsingRulesCreatedCount": "Rules created: {count}", + "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, + "parsingAiSectionTitle": "AI (OpenRouter)", + "parsingAiComingSoon": "AI recognition is coming in a future version.", + "parsingDebugSectionTitle": "Debug", + "parsingDebugInjectTile": "Inject a test notification", + + "inboxTitle": "From notifications", + "inboxSubtitle": "A rule is learned on the first tap: similar messages will be confirmed automatically.", + "inboxEmpty": "Inbox is empty. New notifications will appear here.", + "inboxCreateRule": "Create rule “{merchant} → {category}”", + "@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } }, + "inboxCreateRuleNoCategory": "Create rule for “{merchant}”", + "@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } }, + "inboxConfirmOnce": "Confirm once", + "inboxIgnore": "Ignore", + "inboxApplyAll": "Apply all", + "inboxHideAll": "Hide all", + "inboxUnrecognized": "Not recognized", + "inboxAddManually": "Add manually", + "inboxNoCategory": "No category", + "inboxAccountUnknown": "Account unknown", + "inboxEditRuleTooltip": "Edit before saving", + + "rulesTitle": "Parsing rules", + "rulesSubtitle": "Settings", + "rulesFilterAll": "All", + "rulesFilterMerchants": "Merchants", + "rulesFilterAccounts": "Accounts", + "rulesFilterIgnore": "Ignore", + "rulesEmpty": "No rules yet. Create them from the Inbox in one tap.", + "ruleMatchesCount": "{count, plural, one{{count} match} other{{count} matches}}", + "@ruleMatchesCount": { "placeholders": { "count": { "type": "int" } } }, + "ruleDisabled": "Disabled", + + "ruleEditorNewTitle": "New rule", + "ruleEditorEditTitle": "Rule", + "ruleEditorIfContains": "If the notification contains", + "ruleEditorPatternHint": "e.g. WBSPB", + "ruleEditorModeContains": "Contains", + "ruleEditorModeExact": "Exact", + "ruleEditorModeRegex": "Regex", + "ruleEditorThen": "Then it is:", + "ruleEditorMerchant": "Merchant", + "ruleEditorMerchantHint": "e.g. Wildberries", + "ruleEditorCategory": "Category", + "ruleEditorAccount": "Account", + "ruleEditorAccountUnchanged": "— keep unchanged —", + "ruleEditorAdvanced": "Advanced", + "ruleEditorPriority": "Priority", + "ruleEditorMatchesTitle": "Matches (last 30 days)", + "ruleEditorNoMatches": "No matches in the last 30 days", + "ruleEditorSave": "Save", + "ruleEditorDelete": "Delete", + "ruleEditorEnabledLabel": "Enabled", + + "debugInjectTitle": "Test notification", + "debugInjectSubtitle": "Debug", + "debugInjectPackageLabel": "App package", + "debugInjectTitleLabel": "Title (optional)", + "debugInjectBodyLabel": "Notification text", + "debugInjectSend": "Send to pipeline", + "debugInjectSent": "Sent", + "debugInjectFillSample": "Sample: purchase" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f7ed29c..1b734e2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -883,6 +883,402 @@ abstract class AppLocalizations { /// In ru, this message translates to: /// **'Счёт сохранён, но не удалось сделать его умолчательным.'** String get accountSetDefaultError; + + /// No description provided for @profileParsingTile. + /// + /// In ru, this message translates to: + /// **'Парсинг уведомлений'** + String get profileParsingTile; + + /// No description provided for @parsingSettingsTitle. + /// + /// In ru, this message translates to: + /// **'Парсинг уведомлений'** + String get parsingSettingsTitle; + + /// No description provided for @parsingSettingsSubtitle. + /// + /// In ru, this message translates to: + /// **'Настройки'** + String get parsingSettingsSubtitle; + + /// No description provided for @parsingEnableLabel. + /// + /// In ru, this message translates to: + /// **'Распознавать уведомления'** + String get parsingEnableLabel; + + /// No description provided for @parsingStrictnessLabel. + /// + /// In ru, this message translates to: + /// **'Скорость авто-добавления'** + String get parsingStrictnessLabel; + + /// No description provided for @parsingStrictnessSoft. + /// + /// In ru, this message translates to: + /// **'Мягко'** + String get parsingStrictnessSoft; + + /// No description provided for @parsingStrictnessNormal. + /// + /// In ru, this message translates to: + /// **'Нормально'** + String get parsingStrictnessNormal; + + /// No description provided for @parsingStrictnessStrict. + /// + /// In ru, this message translates to: + /// **'Строго'** + String get parsingStrictnessStrict; + + /// No description provided for @parsingStrictnessHint. + /// + /// In ru, this message translates to: + /// **'Порог применяется к сумме, счёту и типу — не к мерчанту.'** + String get parsingStrictnessHint; + + /// No description provided for @parsingRulesTile. + /// + /// In ru, this message translates to: + /// **'Правила парсинга'** + String get parsingRulesTile; + + /// No description provided for @parsingRulesCreatedCount. + /// + /// In ru, this message translates to: + /// **'Правил создано: {count}'** + String parsingRulesCreatedCount(int count); + + /// No description provided for @parsingAiSectionTitle. + /// + /// In ru, this message translates to: + /// **'AI (OpenRouter)'** + String get parsingAiSectionTitle; + + /// No description provided for @parsingAiComingSoon. + /// + /// In ru, this message translates to: + /// **'ИИ-распознавание появится в следующей версии.'** + String get parsingAiComingSoon; + + /// No description provided for @parsingDebugSectionTitle. + /// + /// In ru, this message translates to: + /// **'Отладка'** + String get parsingDebugSectionTitle; + + /// No description provided for @parsingDebugInjectTile. + /// + /// In ru, this message translates to: + /// **'Вставить тестовое уведомление'** + String get parsingDebugInjectTile; + + /// No description provided for @inboxTitle. + /// + /// In ru, this message translates to: + /// **'Из уведомлений'** + String get inboxTitle; + + /// No description provided for @inboxSubtitle. + /// + /// In ru, this message translates to: + /// **'Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически.'** + String get inboxSubtitle; + + /// No description provided for @inboxEmpty. + /// + /// In ru, this message translates to: + /// **'Inbox пуст. Новые уведомления появятся здесь.'** + String get inboxEmpty; + + /// No description provided for @inboxCreateRule. + /// + /// In ru, this message translates to: + /// **'Создать правило «{merchant} → {category}»'** + String inboxCreateRule(String merchant, String category); + + /// No description provided for @inboxCreateRuleNoCategory. + /// + /// In ru, this message translates to: + /// **'Создать правило для «{merchant}»'** + String inboxCreateRuleNoCategory(String merchant); + + /// No description provided for @inboxConfirmOnce. + /// + /// In ru, this message translates to: + /// **'Подтвердить разово'** + String get inboxConfirmOnce; + + /// No description provided for @inboxIgnore. + /// + /// In ru, this message translates to: + /// **'Игнорировать'** + String get inboxIgnore; + + /// No description provided for @inboxApplyAll. + /// + /// In ru, this message translates to: + /// **'Учесть все'** + String get inboxApplyAll; + + /// No description provided for @inboxHideAll. + /// + /// In ru, this message translates to: + /// **'Скрыть разово'** + String get inboxHideAll; + + /// No description provided for @inboxUnrecognized. + /// + /// In ru, this message translates to: + /// **'Не распознано'** + String get inboxUnrecognized; + + /// No description provided for @inboxAddManually. + /// + /// In ru, this message translates to: + /// **'Добавить вручную'** + String get inboxAddManually; + + /// No description provided for @inboxNoCategory. + /// + /// In ru, this message translates to: + /// **'Без категории'** + String get inboxNoCategory; + + /// No description provided for @inboxAccountUnknown. + /// + /// In ru, this message translates to: + /// **'Счёт не определён'** + String get inboxAccountUnknown; + + /// No description provided for @inboxEditRuleTooltip. + /// + /// In ru, this message translates to: + /// **'Изменить перед сохранением'** + String get inboxEditRuleTooltip; + + /// No description provided for @rulesTitle. + /// + /// In ru, this message translates to: + /// **'Правила парсинга'** + String get rulesTitle; + + /// No description provided for @rulesSubtitle. + /// + /// In ru, this message translates to: + /// **'Настройки'** + String get rulesSubtitle; + + /// No description provided for @rulesFilterAll. + /// + /// In ru, this message translates to: + /// **'Все'** + String get rulesFilterAll; + + /// No description provided for @rulesFilterMerchants. + /// + /// In ru, this message translates to: + /// **'Мерчанты'** + String get rulesFilterMerchants; + + /// No description provided for @rulesFilterAccounts. + /// + /// In ru, this message translates to: + /// **'Счета'** + String get rulesFilterAccounts; + + /// No description provided for @rulesFilterIgnore. + /// + /// In ru, this message translates to: + /// **'Игнор'** + String get rulesFilterIgnore; + + /// No description provided for @rulesEmpty. + /// + /// In ru, this message translates to: + /// **'Правил пока нет. Создавайте их из Inbox в один тап.'** + String get rulesEmpty; + + /// No description provided for @ruleMatchesCount. + /// + /// In ru, this message translates to: + /// **'{count, plural, one{{count} совпадение} few{{count} совпадения} many{{count} совпадений} other{{count} совпадений}}'** + String ruleMatchesCount(int count); + + /// No description provided for @ruleDisabled. + /// + /// In ru, this message translates to: + /// **'Выключено'** + String get ruleDisabled; + + /// No description provided for @ruleEditorNewTitle. + /// + /// In ru, this message translates to: + /// **'Новое правило'** + String get ruleEditorNewTitle; + + /// No description provided for @ruleEditorEditTitle. + /// + /// In ru, this message translates to: + /// **'Правило'** + String get ruleEditorEditTitle; + + /// No description provided for @ruleEditorIfContains. + /// + /// In ru, this message translates to: + /// **'Если уведомление содержит'** + String get ruleEditorIfContains; + + /// No description provided for @ruleEditorPatternHint. + /// + /// In ru, this message translates to: + /// **'напр. WBSPB'** + String get ruleEditorPatternHint; + + /// No description provided for @ruleEditorModeContains. + /// + /// In ru, this message translates to: + /// **'Содержит'** + String get ruleEditorModeContains; + + /// No description provided for @ruleEditorModeExact. + /// + /// In ru, this message translates to: + /// **'Точно'** + String get ruleEditorModeExact; + + /// No description provided for @ruleEditorModeRegex. + /// + /// In ru, this message translates to: + /// **'Regex'** + String get ruleEditorModeRegex; + + /// No description provided for @ruleEditorThen. + /// + /// In ru, this message translates to: + /// **'То это:'** + String get ruleEditorThen; + + /// No description provided for @ruleEditorMerchant. + /// + /// In ru, this message translates to: + /// **'Мерчант'** + String get ruleEditorMerchant; + + /// No description provided for @ruleEditorMerchantHint. + /// + /// In ru, this message translates to: + /// **'Напр. Wildberries'** + String get ruleEditorMerchantHint; + + /// No description provided for @ruleEditorCategory. + /// + /// In ru, this message translates to: + /// **'Категория'** + String get ruleEditorCategory; + + /// No description provided for @ruleEditorAccount. + /// + /// In ru, this message translates to: + /// **'Счёт'** + String get ruleEditorAccount; + + /// No description provided for @ruleEditorAccountUnchanged. + /// + /// In ru, this message translates to: + /// **'— не менять —'** + String get ruleEditorAccountUnchanged; + + /// No description provided for @ruleEditorAdvanced. + /// + /// In ru, this message translates to: + /// **'Дополнительно'** + String get ruleEditorAdvanced; + + /// No description provided for @ruleEditorPriority. + /// + /// In ru, this message translates to: + /// **'Приоритет'** + String get ruleEditorPriority; + + /// No description provided for @ruleEditorMatchesTitle. + /// + /// In ru, this message translates to: + /// **'Совпадает с (последние 30 дней)'** + String get ruleEditorMatchesTitle; + + /// No description provided for @ruleEditorNoMatches. + /// + /// In ru, this message translates to: + /// **'Нет совпадений за 30 дней'** + String get ruleEditorNoMatches; + + /// No description provided for @ruleEditorSave. + /// + /// In ru, this message translates to: + /// **'Сохранить'** + String get ruleEditorSave; + + /// No description provided for @ruleEditorDelete. + /// + /// In ru, this message translates to: + /// **'Удалить'** + String get ruleEditorDelete; + + /// No description provided for @ruleEditorEnabledLabel. + /// + /// In ru, this message translates to: + /// **'Активно'** + String get ruleEditorEnabledLabel; + + /// No description provided for @debugInjectTitle. + /// + /// In ru, this message translates to: + /// **'Тестовое уведомление'** + String get debugInjectTitle; + + /// No description provided for @debugInjectSubtitle. + /// + /// In ru, this message translates to: + /// **'Отладка'** + String get debugInjectSubtitle; + + /// No description provided for @debugInjectPackageLabel. + /// + /// In ru, this message translates to: + /// **'Пакет приложения'** + String get debugInjectPackageLabel; + + /// No description provided for @debugInjectTitleLabel. + /// + /// In ru, this message translates to: + /// **'Заголовок (необязательно)'** + String get debugInjectTitleLabel; + + /// No description provided for @debugInjectBodyLabel. + /// + /// In ru, this message translates to: + /// **'Текст уведомления'** + String get debugInjectBodyLabel; + + /// No description provided for @debugInjectSend. + /// + /// In ru, this message translates to: + /// **'Отправить в обработку'** + String get debugInjectSend; + + /// No description provided for @debugInjectSent. + /// + /// In ru, this message translates to: + /// **'Отправлено'** + String get debugInjectSent; + + /// No description provided for @debugInjectFillSample. + /// + /// In ru, this message translates to: + /// **'Пример: покупка'** + String get debugInjectFillSample; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1a415de..b7ad43f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -455,4 +455,221 @@ class AppLocalizationsEn extends AppLocalizations { @override String get accountSetDefaultError => 'Account saved, but couldn\'t mark it as default.'; + + @override + String get profileParsingTile => 'Notification parsing'; + + @override + String get parsingSettingsTitle => 'Notification parsing'; + + @override + String get parsingSettingsSubtitle => 'Settings'; + + @override + String get parsingEnableLabel => 'Recognize notifications'; + + @override + String get parsingStrictnessLabel => 'Auto-add strictness'; + + @override + String get parsingStrictnessSoft => 'Soft'; + + @override + String get parsingStrictnessNormal => 'Normal'; + + @override + String get parsingStrictnessStrict => 'Strict'; + + @override + String get parsingStrictnessHint => + 'The threshold applies to amount, account and type — not to the merchant.'; + + @override + String get parsingRulesTile => 'Parsing rules'; + + @override + String parsingRulesCreatedCount(int count) { + return 'Rules created: $count'; + } + + @override + String get parsingAiSectionTitle => 'AI (OpenRouter)'; + + @override + String get parsingAiComingSoon => + 'AI recognition is coming in a future version.'; + + @override + String get parsingDebugSectionTitle => 'Debug'; + + @override + String get parsingDebugInjectTile => 'Inject a test notification'; + + @override + String get inboxTitle => 'From notifications'; + + @override + String get inboxSubtitle => + 'A rule is learned on the first tap: similar messages will be confirmed automatically.'; + + @override + String get inboxEmpty => + 'Inbox is empty. New notifications will appear here.'; + + @override + String inboxCreateRule(String merchant, String category) { + return 'Create rule “$merchant → $category”'; + } + + @override + String inboxCreateRuleNoCategory(String merchant) { + return 'Create rule for “$merchant”'; + } + + @override + String get inboxConfirmOnce => 'Confirm once'; + + @override + String get inboxIgnore => 'Ignore'; + + @override + String get inboxApplyAll => 'Apply all'; + + @override + String get inboxHideAll => 'Hide all'; + + @override + String get inboxUnrecognized => 'Not recognized'; + + @override + String get inboxAddManually => 'Add manually'; + + @override + String get inboxNoCategory => 'No category'; + + @override + String get inboxAccountUnknown => 'Account unknown'; + + @override + String get inboxEditRuleTooltip => 'Edit before saving'; + + @override + String get rulesTitle => 'Parsing rules'; + + @override + String get rulesSubtitle => 'Settings'; + + @override + String get rulesFilterAll => 'All'; + + @override + String get rulesFilterMerchants => 'Merchants'; + + @override + String get rulesFilterAccounts => 'Accounts'; + + @override + String get rulesFilterIgnore => 'Ignore'; + + @override + String get rulesEmpty => + 'No rules yet. Create them from the Inbox in one tap.'; + + @override + String ruleMatchesCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count matches', + one: '$count match', + ); + return '$_temp0'; + } + + @override + String get ruleDisabled => 'Disabled'; + + @override + String get ruleEditorNewTitle => 'New rule'; + + @override + String get ruleEditorEditTitle => 'Rule'; + + @override + String get ruleEditorIfContains => 'If the notification contains'; + + @override + String get ruleEditorPatternHint => 'e.g. WBSPB'; + + @override + String get ruleEditorModeContains => 'Contains'; + + @override + String get ruleEditorModeExact => 'Exact'; + + @override + String get ruleEditorModeRegex => 'Regex'; + + @override + String get ruleEditorThen => 'Then it is:'; + + @override + String get ruleEditorMerchant => 'Merchant'; + + @override + String get ruleEditorMerchantHint => 'e.g. Wildberries'; + + @override + String get ruleEditorCategory => 'Category'; + + @override + String get ruleEditorAccount => 'Account'; + + @override + String get ruleEditorAccountUnchanged => '— keep unchanged —'; + + @override + String get ruleEditorAdvanced => 'Advanced'; + + @override + String get ruleEditorPriority => 'Priority'; + + @override + String get ruleEditorMatchesTitle => 'Matches (last 30 days)'; + + @override + String get ruleEditorNoMatches => 'No matches in the last 30 days'; + + @override + String get ruleEditorSave => 'Save'; + + @override + String get ruleEditorDelete => 'Delete'; + + @override + String get ruleEditorEnabledLabel => 'Enabled'; + + @override + String get debugInjectTitle => 'Test notification'; + + @override + String get debugInjectSubtitle => 'Debug'; + + @override + String get debugInjectPackageLabel => 'App package'; + + @override + String get debugInjectTitleLabel => 'Title (optional)'; + + @override + String get debugInjectBodyLabel => 'Notification text'; + + @override + String get debugInjectSend => 'Send to pipeline'; + + @override + String get debugInjectSent => 'Sent'; + + @override + String get debugInjectFillSample => 'Sample: purchase'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d6c080f..f2c4dd7 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -467,4 +467,222 @@ class AppLocalizationsRu extends AppLocalizations { @override String get accountSetDefaultError => 'Счёт сохранён, но не удалось сделать его умолчательным.'; + + @override + String get profileParsingTile => 'Парсинг уведомлений'; + + @override + String get parsingSettingsTitle => 'Парсинг уведомлений'; + + @override + String get parsingSettingsSubtitle => 'Настройки'; + + @override + String get parsingEnableLabel => 'Распознавать уведомления'; + + @override + String get parsingStrictnessLabel => 'Скорость авто-добавления'; + + @override + String get parsingStrictnessSoft => 'Мягко'; + + @override + String get parsingStrictnessNormal => 'Нормально'; + + @override + String get parsingStrictnessStrict => 'Строго'; + + @override + String get parsingStrictnessHint => + 'Порог применяется к сумме, счёту и типу — не к мерчанту.'; + + @override + String get parsingRulesTile => 'Правила парсинга'; + + @override + String parsingRulesCreatedCount(int count) { + return 'Правил создано: $count'; + } + + @override + String get parsingAiSectionTitle => 'AI (OpenRouter)'; + + @override + String get parsingAiComingSoon => + 'ИИ-распознавание появится в следующей версии.'; + + @override + String get parsingDebugSectionTitle => 'Отладка'; + + @override + String get parsingDebugInjectTile => 'Вставить тестовое уведомление'; + + @override + String get inboxTitle => 'Из уведомлений'; + + @override + String get inboxSubtitle => + 'Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически.'; + + @override + String get inboxEmpty => 'Inbox пуст. Новые уведомления появятся здесь.'; + + @override + String inboxCreateRule(String merchant, String category) { + return 'Создать правило «$merchant → $category»'; + } + + @override + String inboxCreateRuleNoCategory(String merchant) { + return 'Создать правило для «$merchant»'; + } + + @override + String get inboxConfirmOnce => 'Подтвердить разово'; + + @override + String get inboxIgnore => 'Игнорировать'; + + @override + String get inboxApplyAll => 'Учесть все'; + + @override + String get inboxHideAll => 'Скрыть разово'; + + @override + String get inboxUnrecognized => 'Не распознано'; + + @override + String get inboxAddManually => 'Добавить вручную'; + + @override + String get inboxNoCategory => 'Без категории'; + + @override + String get inboxAccountUnknown => 'Счёт не определён'; + + @override + String get inboxEditRuleTooltip => 'Изменить перед сохранением'; + + @override + String get rulesTitle => 'Правила парсинга'; + + @override + String get rulesSubtitle => 'Настройки'; + + @override + String get rulesFilterAll => 'Все'; + + @override + String get rulesFilterMerchants => 'Мерчанты'; + + @override + String get rulesFilterAccounts => 'Счета'; + + @override + String get rulesFilterIgnore => 'Игнор'; + + @override + String get rulesEmpty => + 'Правил пока нет. Создавайте их из Inbox в один тап.'; + + @override + String ruleMatchesCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count совпадений', + many: '$count совпадений', + few: '$count совпадения', + one: '$count совпадение', + ); + return '$_temp0'; + } + + @override + String get ruleDisabled => 'Выключено'; + + @override + String get ruleEditorNewTitle => 'Новое правило'; + + @override + String get ruleEditorEditTitle => 'Правило'; + + @override + String get ruleEditorIfContains => 'Если уведомление содержит'; + + @override + String get ruleEditorPatternHint => 'напр. WBSPB'; + + @override + String get ruleEditorModeContains => 'Содержит'; + + @override + String get ruleEditorModeExact => 'Точно'; + + @override + String get ruleEditorModeRegex => 'Regex'; + + @override + String get ruleEditorThen => 'То это:'; + + @override + String get ruleEditorMerchant => 'Мерчант'; + + @override + String get ruleEditorMerchantHint => 'Напр. Wildberries'; + + @override + String get ruleEditorCategory => 'Категория'; + + @override + String get ruleEditorAccount => 'Счёт'; + + @override + String get ruleEditorAccountUnchanged => '— не менять —'; + + @override + String get ruleEditorAdvanced => 'Дополнительно'; + + @override + String get ruleEditorPriority => 'Приоритет'; + + @override + String get ruleEditorMatchesTitle => 'Совпадает с (последние 30 дней)'; + + @override + String get ruleEditorNoMatches => 'Нет совпадений за 30 дней'; + + @override + String get ruleEditorSave => 'Сохранить'; + + @override + String get ruleEditorDelete => 'Удалить'; + + @override + String get ruleEditorEnabledLabel => 'Активно'; + + @override + String get debugInjectTitle => 'Тестовое уведомление'; + + @override + String get debugInjectSubtitle => 'Отладка'; + + @override + String get debugInjectPackageLabel => 'Пакет приложения'; + + @override + String get debugInjectTitleLabel => 'Заголовок (необязательно)'; + + @override + String get debugInjectBodyLabel => 'Текст уведомления'; + + @override + String get debugInjectSend => 'Отправить в обработку'; + + @override + String get debugInjectSent => 'Отправлено'; + + @override + String get debugInjectFillSample => 'Пример: покупка'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1377088..3f3278f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -199,5 +199,81 @@ "accountValidationNameTooLong": "Слишком длинное название (макс. 50 символов)", "accountSaveError": "Не удалось сохранить счёт. Попробуйте ещё раз.", "accountArchiveError": "Не удалось архивировать счёт. Попробуйте ещё раз.", - "accountSetDefaultError": "Счёт сохранён, но не удалось сделать его умолчательным." + "accountSetDefaultError": "Счёт сохранён, но не удалось сделать его умолчательным.", + + "profileParsingTile": "Парсинг уведомлений", + + "parsingSettingsTitle": "Парсинг уведомлений", + "parsingSettingsSubtitle": "Настройки", + "parsingEnableLabel": "Распознавать уведомления", + "parsingStrictnessLabel": "Скорость авто-добавления", + "parsingStrictnessSoft": "Мягко", + "parsingStrictnessNormal": "Нормально", + "parsingStrictnessStrict": "Строго", + "parsingStrictnessHint": "Порог применяется к сумме, счёту и типу — не к мерчанту.", + "parsingRulesTile": "Правила парсинга", + "parsingRulesCreatedCount": "Правил создано: {count}", + "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, + "parsingAiSectionTitle": "AI (OpenRouter)", + "parsingAiComingSoon": "ИИ-распознавание появится в следующей версии.", + "parsingDebugSectionTitle": "Отладка", + "parsingDebugInjectTile": "Вставить тестовое уведомление", + + "inboxTitle": "Из уведомлений", + "inboxSubtitle": "Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически.", + "inboxEmpty": "Inbox пуст. Новые уведомления появятся здесь.", + "inboxCreateRule": "Создать правило «{merchant} → {category}»", + "@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } }, + "inboxCreateRuleNoCategory": "Создать правило для «{merchant}»", + "@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } }, + "inboxConfirmOnce": "Подтвердить разово", + "inboxIgnore": "Игнорировать", + "inboxApplyAll": "Учесть все", + "inboxHideAll": "Скрыть разово", + "inboxUnrecognized": "Не распознано", + "inboxAddManually": "Добавить вручную", + "inboxNoCategory": "Без категории", + "inboxAccountUnknown": "Счёт не определён", + "inboxEditRuleTooltip": "Изменить перед сохранением", + + "rulesTitle": "Правила парсинга", + "rulesSubtitle": "Настройки", + "rulesFilterAll": "Все", + "rulesFilterMerchants": "Мерчанты", + "rulesFilterAccounts": "Счета", + "rulesFilterIgnore": "Игнор", + "rulesEmpty": "Правил пока нет. Создавайте их из Inbox в один тап.", + "ruleMatchesCount": "{count, plural, one{{count} совпадение} few{{count} совпадения} many{{count} совпадений} other{{count} совпадений}}", + "@ruleMatchesCount": { "placeholders": { "count": { "type": "int" } } }, + "ruleDisabled": "Выключено", + + "ruleEditorNewTitle": "Новое правило", + "ruleEditorEditTitle": "Правило", + "ruleEditorIfContains": "Если уведомление содержит", + "ruleEditorPatternHint": "напр. WBSPB", + "ruleEditorModeContains": "Содержит", + "ruleEditorModeExact": "Точно", + "ruleEditorModeRegex": "Regex", + "ruleEditorThen": "То это:", + "ruleEditorMerchant": "Мерчант", + "ruleEditorMerchantHint": "Напр. Wildberries", + "ruleEditorCategory": "Категория", + "ruleEditorAccount": "Счёт", + "ruleEditorAccountUnchanged": "— не менять —", + "ruleEditorAdvanced": "Дополнительно", + "ruleEditorPriority": "Приоритет", + "ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)", + "ruleEditorNoMatches": "Нет совпадений за 30 дней", + "ruleEditorSave": "Сохранить", + "ruleEditorDelete": "Удалить", + "ruleEditorEnabledLabel": "Активно", + + "debugInjectTitle": "Тестовое уведомление", + "debugInjectSubtitle": "Отладка", + "debugInjectPackageLabel": "Пакет приложения", + "debugInjectTitleLabel": "Заголовок (необязательно)", + "debugInjectBodyLabel": "Текст уведомления", + "debugInjectSend": "Отправить в обработку", + "debugInjectSent": "Отправлено", + "debugInjectFillSample": "Пример: покупка" } diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index a5af90d..7b8f148 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -9,6 +9,11 @@ import '../../features/analytics/presentation/screens/analytics_screen.dart'; import '../../features/categories/presentation/screens/categories_list_screen.dart'; import '../../features/categories/presentation/screens/category_form_screen.dart'; import '../../features/home/presentation/screens/home_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_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/profile/presentation/screens/profile_screen.dart'; import '../../features/transactions/presentation/screens/transaction_form_screen.dart'; import '../../features/user/application/active_user_controller.dart'; @@ -96,6 +101,34 @@ GoRouter appRouter(Ref ref) { AccountFormScreen(accountId: state.pathParameters['id']), ), ), + GoRoute( + path: AppRoutes.inbox, + pageBuilder: (context, state) => + _slideUpPage(state, const InboxScreen()), + ), + GoRoute( + path: AppRoutes.parsingSettings, + builder: (context, state) => const ParsingSettingsScreen(), + ), + GoRoute( + path: AppRoutes.parsingRules, + builder: (context, state) => const RulesListScreen(), + ), + // 'new' должен идти раньше ':id', иначе перехватится как id. + GoRoute( + path: AppRoutes.parsingRuleNew, + builder: (context, state) => + RuleEditorScreen(prefill: state.extra as RuleEditorPrefill?), + ), + GoRoute( + path: AppRoutes.parsingRuleEditPattern, + builder: (context, state) => + RuleEditorScreen(ruleId: state.pathParameters['id']), + ), + GoRoute( + path: AppRoutes.parsingDebugInject, + builder: (context, state) => const DebugInjectScreen(), + ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) => AppScaffold( navigationShell: navigationShell, diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart index 623cd39..d5af63f 100644 --- a/lib/src/app/router/app_routes.dart +++ b/lib/src/app/router/app_routes.dart @@ -19,4 +19,13 @@ class AppRoutes { static const accountNew = '/accounts/new'; static const accountEditPattern = '/accounts/edit/:id'; static String accountEdit(String id) => '/accounts/edit/$id'; + + // Notification parsing + static const inbox = '/inbox'; + static const parsingSettings = '/settings/parsing'; + static const parsingRules = '/settings/parsing/rules'; + static const parsingRuleNew = '/settings/parsing/rules/new'; + static const parsingRuleEditPattern = '/settings/parsing/rules/:id'; + static String parsingRuleEdit(String id) => '/settings/parsing/rules/$id'; + static const parsingDebugInject = '/settings/parsing/debug'; } diff --git a/lib/src/features/home/presentation/screens/home_screen.dart b/lib/src/features/home/presentation/screens/home_screen.dart index 8d5e6aa..4b917d4 100644 --- a/lib/src/features/home/presentation/screens/home_screen.dart +++ b/lib/src/features/home/presentation/screens/home_screen.dart @@ -11,6 +11,7 @@ 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 '../../../notification_parsing/application/parsing_worker.dart'; import '../../../transactions/domain/entities/transaction.dart'; import '../../../user/application/active_user_controller.dart'; import '../month_summary.dart'; @@ -58,6 +59,8 @@ class _HomeContent extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final locale = Localizations.localeOf(context).toString(); + // Активируем ParsingWorker (keepAlive): слушает raw_messages.pending. + ref.watch(parsingWorkerProvider(userId)); final categories = ref.watch(categoriesStreamProvider(userId)).value ?? const []; diff --git a/lib/src/features/home/presentation/widgets/month_header.dart b/lib/src/features/home/presentation/widgets/month_header.dart index 743c704..6f76e63 100644 --- a/lib/src/features/home/presentation/widgets/month_header.dart +++ b/lib/src/features/home/presentation/widgets/month_header.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../../app/l10n/l10n.dart'; +import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../notification_parsing/application/inbox_controller.dart'; +import '../../../user/application/active_user_controller.dart'; import '../state/selected_category_filter.dart'; class MonthHeader extends ConsumerWidget { @@ -82,18 +86,37 @@ class MonthHeader extends ConsumerWidget { icon: Icon(Icons.search, size: 20, color: p.ink2), ), const SizedBox(width: 4), - IconButton( - onPressed: () {}, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - icon: Icon(Icons.notifications_outlined, size: 20, color: p.ink2), - ), + _InboxButton(color: p.ink2), ], ), ); } } +/// Кнопка-бэдж Inbox: ✉N появляется при наличии непросмотренных уведомлений. +class _InboxButton extends ConsumerWidget { + const _InboxButton({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userId = ref.watch(activeUserControllerProvider).value?.id; + final count = + userId == null ? 0 : (ref.watch(inboxCountProvider(userId)).value ?? 0); + + final button = IconButton( + onPressed: () => context.push(AppRoutes.inbox), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon(Icons.notifications_outlined, size: 20, color: color), + ); + + if (count == 0) return button; + return Badge.count(count: count, child: button); + } +} + class _NavButton extends StatelessWidget { const _NavButton({ required this.icon, diff --git a/lib/src/features/notification_parsing/application/inbox_controller.dart b/lib/src/features/notification_parsing/application/inbox_controller.dart new file mode 100644 index 0000000..91ce4c0 --- /dev/null +++ b/lib/src/features/notification_parsing/application/inbox_controller.dart @@ -0,0 +1,205 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../accounts/application/account_providers.dart'; +import '../../transactions/application/transactions_controller.dart'; +import '../data/parser/draft_codec.dart'; +import '../domain/entities/parse_draft.dart'; +import '../domain/entities/raw_message.dart'; +import '../domain/enums.dart'; +import 'notification_parsing_providers.dart'; + +part 'inbox_controller.g.dart'; + +/// Сообщения, ожидающие подтверждения в Inbox. +@riverpod +Stream> inboxMessages(Ref ref, String userId) => + ref.watch(rawMessagesRepositoryProvider).watchInbox(userId); + +/// Реактивный счётчик Inbox — для бэджа на Home. +@riverpod +Stream inboxCount(Ref ref, String userId) => + ref.watch(rawMessagesRepositoryProvider).watchInboxCount(userId); + +/// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя. +@Riverpod(keepAlive: true) +class InboxController extends _$InboxController { + @override + AsyncValue build() => const AsyncData(null); + + /// «Создать правило»: транзакция + parse_rule + удаление кандидата. + /// Все последующие похожие сообщения auto-apply молча. + Future createRule({ + required String userId, + required RawMessage message, + required ParseDraft draft, + required String accountId, + String? categoryId, + required String merchantCanonical, + required String pattern, + MatchMode matchMode = MatchMode.contains, + bool bindAccount = true, + }) async { + state = const AsyncLoading(); + try { + final tx = + await ref.read(transactionsControllerProvider.notifier).createTransaction( + userId: userId, + accountId: accountId, + categoryId: categoryId, + type: draft.type, + amount: draft.amount, + date: draft.dateTime ?? message.receivedAt, + merchant: merchantCanonical, + rawMessageId: message.id, + ); + + await ref.read(parseRulesRepositoryProvider).create( + userId: userId, + kind: ParseRuleKind.merchantToCategory, + matchMode: matchMode, + pattern: pattern, + merchantCanonical: merchantCanonical, + categoryId: categoryId, + ); + + if (draft.merchantRaw != null) { + await ref + .read(ruleCandidatesRepositoryProvider) + .deleteByRawValue(userId, draft.merchantRaw!); + } + + await ref.read(rawMessagesRepositoryProvider).linkTransaction(message.id, tx.id); + if (bindAccount) await _maybeBind(userId, message, draft, accountId); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + /// «Подтвердить разово»: только транзакция (правило не создаём), + /// усиливаем кандидата для будущего предложения. + Future confirmOnce({ + required String userId, + required RawMessage message, + required ParseDraft draft, + required String accountId, + String? categoryId, + bool bindAccount = true, + }) async { + state = const AsyncLoading(); + try { + final tx = + await ref.read(transactionsControllerProvider.notifier).createTransaction( + userId: userId, + accountId: accountId, + categoryId: categoryId, + type: draft.type, + amount: draft.amount, + date: draft.dateTime ?? message.receivedAt, + merchant: draft.merchantCanonical ?? draft.merchantRaw, + rawMessageId: message.id, + ); + await ref.read(rawMessagesRepositoryProvider).linkTransaction(message.id, tx.id); + + if (draft.merchantRaw != null && categoryId != null) { + await ref.read(ruleCandidatesRepositoryProvider).observe( + userId: userId, + kind: ParseRuleKind.merchantToCategory, + rawValue: draft.merchantRaw!, + resolvedValue: categoryId, + ); + } + if (bindAccount) await _maybeBind(userId, message, draft, accountId); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + /// «Игнорировать»: статус ignored, без правила. + Future ignore(RawMessage message) async { + await ref + .read(rawMessagesRepositoryProvider) + .updateStatus(message.id, RawMessageStatus.ignored); + } + + /// «Игнорировать» + правило-исключение (kind=ignore). + Future ignoreWithRule({ + required String userId, + required RawMessage message, + required String pattern, + MatchMode matchMode = MatchMode.contains, + }) async { + state = const AsyncLoading(); + try { + await ref.read(parseRulesRepositoryProvider).create( + userId: userId, + kind: ParseRuleKind.ignore, + matchMode: matchMode, + pattern: pattern, + ); + await ref + .read(rawMessagesRepositoryProvider) + .updateStatus(message.id, RawMessageStatus.ignored); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + /// «Учесть все транзакции»: массовое «подтвердить разово». + Future applyAll(String userId) async { + final messages = + await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first; + final defaultAccount = + await ref.read(accountRepositoryProvider).watchDefault(userId).first; + for (final msg in messages) { + final bundle = decodeDraftBundle(msg.draftJson); + if (bundle == null) continue; + final accountId = bundle.draft.accountId ?? defaultAccount?.id; + if (accountId == null) continue; + await confirmOnce( + userId: userId, + message: msg, + draft: bundle.draft, + accountId: accountId, + categoryId: bundle.draft.categoryId ?? bundle.suggestion?.categoryId, + ); + } + } + + /// «Скрыть разово»: убрать из Inbox без создания транзакций. + Future hideAll(String userId) async { + final messages = + await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first; + final repo = ref.read(rawMessagesRepositoryProvider); + for (final msg in messages) { + await repo.updateStatus(msg.id, RawMessageStatus.ignored); + } + } + + /// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее + /// сообщение того же мерчанта прошло gate молча (account score 100). + Future _maybeBind( + 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, + ); + } +} diff --git a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart new file mode 100644 index 0000000..45a795e --- /dev/null +++ b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart @@ -0,0 +1,33 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/providers/database_provider.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 '../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'; + +part 'notification_parsing_providers.g.dart'; + +/// keepAlive: репозитории держат открытые Drift-стримы — не пересоздавать. + +@Riverpod(keepAlive: true) +RawMessagesRepository rawMessagesRepository(Ref ref) => + RawMessagesRepositoryImpl(ref.watch(appDatabaseProvider).rawMessagesDao); + +@Riverpod(keepAlive: true) +ParseRulesRepository parseRulesRepository(Ref ref) => + ParseRulesRepositoryImpl(ref.watch(appDatabaseProvider).parseRulesDao); + +@Riverpod(keepAlive: true) +RuleCandidatesRepository ruleCandidatesRepository(Ref ref) => + RuleCandidatesRepositoryImpl( + ref.watch(appDatabaseProvider).ruleCandidatesDao); + +@Riverpod(keepAlive: true) +AccountBindingsRepository accountBindingsRepository(Ref ref) => + AccountBindingsRepositoryImpl( + ref.watch(appDatabaseProvider).accountBindingsDao); diff --git a/lib/src/features/notification_parsing/application/parsing_settings_controller.dart b/lib/src/features/notification_parsing/application/parsing_settings_controller.dart new file mode 100644 index 0000000..e949619 --- /dev/null +++ b/lib/src/features/notification_parsing/application/parsing_settings_controller.dart @@ -0,0 +1,58 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/providers/database_provider.dart'; + +part 'parsing_settings_controller.g.dart'; + +/// Настройки фичи парсинга (Phase 1) — хранятся в key-value `app_preferences`, +/// без изменения схемы. +class ParsingSettings { + const ParsingSettings({required this.enabled, required this.strictness}); + + /// Распознавать уведомления (мастер-тумблер всей фичи). + final bool enabled; + + /// Порог строгости авто-добавления для прочих полей (§8.7): 75 / 85 / 95. + final int strictness; + + ParsingSettings copyWith({bool? enabled, int? strictness}) => ParsingSettings( + enabled: enabled ?? this.enabled, + strictness: strictness ?? this.strictness, + ); +} + +const _kEnabled = 'parsing_enabled'; +const _kStrictness = 'parsing_auto_apply_strictness'; +const _defaultSettings = ParsingSettings(enabled: true, strictness: 85); + +@Riverpod(keepAlive: true) +class ParsingSettingsController extends _$ParsingSettingsController { + @override + Future build() async { + final dao = ref.watch(appDatabaseProvider).settingsDao; + final enabledStr = await dao.getPreference(_kEnabled); + final strictnessStr = await dao.getPreference(_kStrictness); + return ParsingSettings( + enabled: enabledStr == null ? _defaultSettings.enabled : enabledStr == 'true', + strictness: int.tryParse(strictnessStr ?? '') ?? _defaultSettings.strictness, + ); + } + + Future setEnabled(bool value) async { + await ref + .read(appDatabaseProvider) + .settingsDao + .setPreference(_kEnabled, '$value'); + final current = state.value ?? _defaultSettings; + state = AsyncData(current.copyWith(enabled: value)); + } + + Future setStrictness(int value) async { + await ref + .read(appDatabaseProvider) + .settingsDao + .setPreference(_kStrictness, '$value'); + final current = state.value ?? _defaultSettings; + state = AsyncData(current.copyWith(strictness: value)); + } +} diff --git a/lib/src/features/notification_parsing/application/parsing_worker.dart b/lib/src/features/notification_parsing/application/parsing_worker.dart new file mode 100644 index 0000000..34dcaa7 --- /dev/null +++ b/lib/src/features/notification_parsing/application/parsing_worker.dart @@ -0,0 +1,221 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../categories/application/category_providers.dart'; +import '../../transactions/application/transactions_controller.dart'; +import '../data/parser/account_resolver.dart'; +import '../data/parser/confidence_scorer.dart'; +import '../data/parser/decision_gate.dart'; +import '../data/parser/draft_codec.dart'; +import '../data/parser/regex_parser.dart'; +import '../data/parser/rule_lookup.dart'; +import '../data/parser/rule_suggester.dart'; +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/enums.dart'; +import 'notification_parsing_providers.dart'; +import 'parsing_settings_controller.dart'; + +part 'parsing_worker.g.dart'; + +/// Поток необработанных сообщений — вход воркера. +@riverpod +Stream> pendingMessages(Ref ref, String userId) => + ref.watch(rawMessagesRepositoryProvider).watchPending(userId); + +/// ParsingWorker (§5): слушает `raw_messages.pending` и прогоняет pipeline +/// regex → resolver → rule_lookup → suggester → scorer → gate по одному. +/// +/// Идемпотентен: при возврате сообщения в `pending` перепарсивается. +/// Провайдер `keepAlive` — активируется чтением из HomeScreen. +@Riverpod(keepAlive: true) +class ParsingWorker extends _$ParsingWorker { + final Set _inFlight = {}; + + @override + void build(String userId) { + final sub = ref.listen( + pendingMessagesProvider(userId), + (_, next) { + final list = next.value; + if (list != null && list.isNotEmpty) { + _drain(userId, list); + } + }, + fireImmediately: true, + ); + ref.onDispose(sub.close); + } + + Future _drain(String userId, List pending) async { + for (final msg in pending) { + if (_inFlight.contains(msg.id)) continue; + _inFlight.add(msg.id); + final repo = ref.read(rawMessagesRepositoryProvider); + try { + await repo.incrementParseAttempts(msg.id); + await _process(userId, msg); + } catch (e) { + await repo.updateAfterParse( + id: msg.id, + status: RawMessageStatus.failed, + ); + } finally { + _inFlight.remove(msg.id); + } + } + } + + Future _process(String userId, RawMessage msg) async { + final repo = ref.read(rawMessagesRepositoryProvider); + + final settings = await ref.read(parsingSettingsControllerProvider.future); + if (!settings.enabled) return; // фича выключена — оставляем pending. + + // 1. Regex (Phase 1 — без AI fallback). + final parsed = const RegexParser().parse(msg); + if (parsed == null) { + // Не распознано: баланс/реклама → ignored, иначе → Inbox (вручную). + if (looksLikeNonTransaction(msg.body)) { + await repo.updateStatus(msg.id, RawMessageStatus.ignored); + } else { + await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox); + } + return; + } + + // 3. Разрешение счёта. + final resolver = + AccountResolver(ref.read(accountBindingsRepositoryProvider)); + final resolution = await resolver.resolve( + userId: userId, + packageName: msg.packageName, + cardLast4: parsed.cardLast4, + phone: parsed.counterpartyPhone, + ); + var draft = parsed.copyWith(accountId: resolution.accountId); + + // 4. Правила: исключение → ignored; иначе ищем merchantToCategory. + final rules = + await ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId); + if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft.merchantRaw) != + null) { + await repo.updateStatus(msg.id, RawMessageStatus.ignored); + return; + } + final rule = + findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw); + + RuleCandidate? candidate; + if (draft.merchantRaw != null) { + candidate = await ref.read(ruleCandidatesRepositoryProvider).findByRawValue( + userId, + ParseRuleKind.merchantToCategory, + draft.merchantRaw!, + ); + } + + // Применяем действие правила к draft. + if (rule != null) { + draft = draft.copyWith( + merchantCanonical: rule.merchantCanonical, + categoryId: rule.categoryId, + accountId: draft.accountId ?? rule.accountId, + ); + } + + // Предложение правила для Inbox (только для незнакомого мерчанта). + RuleSuggestion? suggestion; + if (rule == null && draft.merchantRaw != null) { + suggestion = + suggestRule(merchantRaw: draft.merchantRaw!, categoryCandidate: candidate); + if (suggestion.categoryId != null) { + final cat = await ref + .read(categoryRepositoryProvider) + .findById(suggestion.categoryId!); + suggestion = suggestion.copyWith(categoryName: cat?.name); + } + } + + // 6. Confidence. + final scores = scoreDraft( + draft: draft, + message: msg, + accountScore: resolution.score, + merchantRule: rule, + merchantCandidate: candidate, + categoryCandidate: candidate, + ); + + // 8. Gate. + final decision = decide( + merchantRule: rule, + sanityPassed: true, + scores: scores, + strictness: settings.strictness, + amountMinor: draft.amount, + hasAccount: draft.accountId != null, + ); + + if (decision == GateDecision.autoApply) { + await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId); + } else { + await repo.updateAfterParse( + id: msg.id, + status: RawMessageStatus.inbox, + draftJson: encodeDraftBundle(draft, suggestion), + confidenceAmount: scores.amount, + confidenceAccount: scores.account, + confidenceType: scores.type, + confidenceMerchant: scores.merchant, + confidenceCategory: scores.category, + ); + } + } + + Future _autoApply( + String userId, + RawMessage msg, + ParseDraft draft, + rule, + FieldScores scores, + String? bindingId, + ) async { + final repo = ref.read(rawMessagesRepositoryProvider); + final tx = + await ref.read(transactionsControllerProvider.notifier).createTransaction( + userId: userId, + accountId: draft.accountId!, + categoryId: draft.categoryId, + type: draft.type, + amount: draft.amount, + date: draft.dateTime ?? msg.receivedAt, + merchant: draft.merchantCanonical ?? draft.merchantRaw, + rawMessageId: msg.id, + autoApplied: true, + appliedByRuleId: rule.id, + ); + + await repo.updateAfterParse( + id: msg.id, + status: RawMessageStatus.applied, + draftJson: encodeDraftBundle(draft, null), + confidenceAmount: scores.amount, + confidenceAccount: scores.account, + confidenceType: scores.type, + confidenceMerchant: scores.merchant, + confidenceCategory: scores.category, + ); + await repo.linkTransaction(msg.id, tx.id); + + await ref + .read(parseRulesRepositoryProvider) + .incrementMatchCount(rule.id, DateTime.now()); + if (bindingId != null) { + await ref + .read(accountBindingsRepositoryProvider) + .incrementMatchCount(bindingId); + } + } +} diff --git a/lib/src/features/notification_parsing/application/rules_controller.dart b/lib/src/features/notification_parsing/application/rules_controller.dart new file mode 100644 index 0000000..fc98967 --- /dev/null +++ b/lib/src/features/notification_parsing/application/rules_controller.dart @@ -0,0 +1,90 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/parser/rule_lookup.dart'; +import '../domain/entities/parse_rule.dart'; +import '../domain/entities/raw_message.dart'; +import '../domain/enums.dart'; +import 'notification_parsing_providers.dart'; + +part 'rules_controller.g.dart'; + +/// Все правила пользователя — для экрана «Правила парсинга». +@riverpod +Stream> parseRulesList(Ref ref, String userId) => + ref.watch(parseRulesRepositoryProvider).watchByUser(userId); + +/// Одно правило по id — для редактора. +@riverpod +Future parseRuleById(Ref ref, String id) => + ref.watch(parseRulesRepositoryProvider).findById(id); + +/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном. +@riverpod +Future> ruleMatchPreview( + Ref ref, + String userId, + String pattern, + MatchMode mode, +) async { + if (pattern.trim().isEmpty) return const []; + final since = DateTime.now().subtract(const Duration(days: 30)); + final messages = + await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since); + return messages + .where((m) => patternMatches(pattern, mode, body: m.body)) + .toList(); +} + +/// CRUD над правилами парсинга. +@Riverpod(keepAlive: true) +class RulesController extends _$RulesController { + @override + AsyncValue build() => const AsyncData(null); + + Future create({ + required String userId, + required ParseRuleKind kind, + required MatchMode matchMode, + required String pattern, + int priority = 0, + String? merchantCanonical, + String? categoryId, + String? accountId, + }) async { + state = const AsyncLoading(); + try { + final rule = await ref.read(parseRulesRepositoryProvider).create( + userId: userId, + kind: kind, + matchMode: matchMode, + pattern: pattern, + priority: priority, + merchantCanonical: merchantCanonical, + categoryId: categoryId, + accountId: accountId, + ); + state = const AsyncData(null); + return rule; + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future update(ParseRule rule) async { + state = const AsyncLoading(); + try { + await ref.read(parseRulesRepositoryProvider).update(rule); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future setEnabled(String id, {required bool enabled}) => + ref.read(parseRulesRepositoryProvider).setEnabled(id, enabled: enabled); + + Future delete(String id) => + ref.read(parseRulesRepositoryProvider).deleteById(id); +} diff --git a/lib/src/features/notification_parsing/data/bank_templates/bank_templates_catalog.dart b/lib/src/features/notification_parsing/data/bank_templates/bank_templates_catalog.dart new file mode 100644 index 0000000..441e192 --- /dev/null +++ b/lib/src/features/notification_parsing/data/bank_templates/bank_templates_catalog.dart @@ -0,0 +1,63 @@ +import '../../../../core/database/converters/enum_converters.dart'; +import '../../domain/entities/bank_template.dart'; + +/// Встроенный набор RU-шаблонов (regex first, §6). +/// +/// Шаблоны универсальны (не привязаны к конкретным банкам) и покрывают +/// распространённые форматы РФ: покупка/списание с карты, зачисление, +/// СБП-перевод по телефону, возврат. Если ни один не совпал — этап 2 (AI, +/// Phase 2); в Phase 1 это `parsed_partial` → Inbox. +/// +/// Группы извлечения нумерованные (`$1`, `$2`, …) — соответствуют capture- +/// группам в [BankTemplate.pattern]. Матч регистронезависимый. +const List bankTemplatesCatalog = [ + // 1. Покупка: сумма → ₽ → *last4 → мерчант (Сбер-подобный формат). + // «Покупка 1240р Карта *3456 PYATEROCHKA 14:05 Баланс: 5000р» + BankTemplate( + key: 'purchase_amount_card_merchant_ru', + pattern: + r'(?:Покупка|Оплата|Списание)\s+(\d[\d\s.,  ]*)\s*(?:₽|руб\.?|р\.?|RUB)[\s\S]*?\*(\d{4})\s+([^\d\n][^\n]*?)(?=\s+\d{1,2}[:.]\d{2}|\s+Баланс|\s+Доступно|[.;]|$)', + extract: {'amount': r'$1', 'cardLast4': r'$2', 'merchant': r'$3'}, + type: TransactionType.expense, + ), + + // 2. Покупка: *last4 → сумма ₽ → мерчант (Тинькофф-подобный формат). + // «Покупка. Карта *3456. 1 240 ₽. Пятёрочка. Доступно 5 000 ₽» + BankTemplate( + key: 'purchase_card_amount_merchant_ru', + pattern: + r'(?:Покупка|Оплата|Списание)[\s\S]*?\*(\d{4})[\s\S]*?(\d[\d\s.,  ]*)\s*(?:₽|руб\.?|RUB)\.?\s+([^\d\n][^\n.]*?)(?=[.]|\s+Доступно|\s+Баланс|$)', + extract: {'cardLast4': r'$1', 'amount': r'$2', 'merchant': r'$3'}, + type: TransactionType.expense, + ), + + // 3. Зачисление / пополнение / поступление. + // «Зачисление 5000 ₽ Карта *3456 ...» + BankTemplate( + key: 'income_card_ru', + pattern: + r'(?:Зачисление|Пополнение|Поступление)\s+(\d[\d\s.,  ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?\*(\d{4})', + extract: {'amount': r'$1', 'cardLast4': r'$2'}, + type: TransactionType.income, + ), + + // 4. СБП-перевод по телефону. + // «Перевод 1000 ₽ на +79991234567 ...» + BankTemplate( + key: 'sbp_phone_ru', + pattern: + r'(?:Перевод|СБП|Отправлен[оы]?)[\s\S]*?(\d[\d\s.,  ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?(\+7\d{10})', + extract: {'amount': r'$1', 'phone': r'$2'}, + type: TransactionType.expense, + ), + + // 5. Возврат покупки. + // «Возврат 1240 ₽ Карта *3456 ...» + BankTemplate( + key: 'refund_card_ru', + pattern: + r'(?:Возврат|Refund)\s+(\d[\d\s.,  ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?\*(\d{4})', + extract: {'amount': r'$1', 'cardLast4': r'$2'}, + type: TransactionType.income, + ), +]; 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 3d61944..84f3b3a 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 @@ -48,6 +48,17 @@ class RawMessagesDao extends DatabaseAccessor (select(rawMessagesTable)..where((t) => t.id.equals(id))) .getSingleOrNull(); + /// Последние сообщения пользователя начиная с [since] — для live-превью + /// в редакторе правил (матч паттерна по реальным сообщениям). + Future> getRecentByUser( + String userId, DateTime since) => + (select(rawMessagesTable) + ..where((t) => + t.userId.equals(userId) & + t.receivedAt.isBiggerOrEqualValue(since)) + ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) + .get(); + /// Поиск по dedupHash для идемпотентной вставки. Future findByDedupHash( String userId, String dedupHash) => 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 new file mode 100644 index 0000000..1956a42 --- /dev/null +++ b/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart @@ -0,0 +1,17 @@ +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, + matchCount: matchCount, + createdAt: createdAt, + ); +} diff --git a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart new file mode 100644 index 0000000..b414372 --- /dev/null +++ b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart @@ -0,0 +1,22 @@ +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/parse_rule.dart'; + +/// Маппер: строка Drift → доменная сущность [ParseRule]. +extension ParseRuleMapper on ParseRulesTableData { + ParseRule toDomain() => ParseRule( + id: id, + userId: userId, + kind: kind, + matchMode: matchMode, + pattern: pattern, + priority: priority, + matchCount: matchCount, + weight: weight, + lastMatchAt: lastMatchAt, + merchantCanonical: merchantCanonical, + categoryId: categoryId, + accountId: accountId, + enabled: enabled, + createdAt: createdAt, + ); +} diff --git a/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart b/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart new file mode 100644 index 0000000..e129d9a --- /dev/null +++ b/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart @@ -0,0 +1,26 @@ +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/raw_message.dart'; + +/// Маппер: строка Drift → доменная сущность [RawMessage]. +extension RawMessageMapper on RawMessagesTableData { + RawMessage toDomain() => RawMessage( + id: id, + userId: userId, + packageName: packageName, + title: title, + body: body, + receivedAt: receivedAt, + dedupHash: dedupHash, + status: status, + parseAttemptCount: parseAttemptCount, + lastParseError: lastParseError, + draftJson: draftJson, + confidenceAmount: confidenceAmount, + confidenceAccount: confidenceAccount, + confidenceType: confidenceType, + confidenceMerchant: confidenceMerchant, + confidenceCategory: confidenceCategory, + transactionId: transactionId, + createdAt: createdAt, + ); +} diff --git a/lib/src/features/notification_parsing/data/mappers/rule_candidate_mapper.dart b/lib/src/features/notification_parsing/data/mappers/rule_candidate_mapper.dart new file mode 100644 index 0000000..18285a0 --- /dev/null +++ b/lib/src/features/notification_parsing/data/mappers/rule_candidate_mapper.dart @@ -0,0 +1,16 @@ +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/rule_candidate.dart'; + +/// Маппер: строка Drift → доменная сущность [RuleCandidate]. +extension RuleCandidateMapper on RuleCandidatesTableData { + RuleCandidate toDomain() => RuleCandidate( + id: id, + userId: userId, + kind: kind, + rawValue: rawValue, + resolvedValue: resolvedValue, + seenCount: seenCount, + firstSeenAt: firstSeenAt, + lastSeenAt: lastSeenAt, + ); +} diff --git a/lib/src/features/notification_parsing/data/parser/account_resolver.dart b/lib/src/features/notification_parsing/data/parser/account_resolver.dart new file mode 100644 index 0000000..1320f07 --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/account_resolver.dart @@ -0,0 +1,64 @@ +import '../../domain/repositories/account_bindings_repository.dart'; + +/// Результат разрешения счёта из уведомления (шаг 3 pipeline). +class AccountResolution { + const AccountResolution({ + required this.accountId, + required this.score, + this.bindingId, + }); + + /// Разрешённый счёт (null, если binding не нашёлся). + final String? accountId; + + /// Confidence по счёту 0..100 (§8.2). + final int score; + + /// id сработавшей привязки — для инкремента matchCount. + final String? bindingId; +} + +/// Разрешает `accountId` по `account_bindings` (карта/телефон → счёт). +class AccountResolver { + const AccountResolver(this._bindings); + + final AccountBindingsRepository _bindings; + + Future resolve({ + required String userId, + required String packageName, + String? cardLast4, + String? phone, + }) async { + if (cardLast4 != null) { + final b = + await _bindings.findByPackageAndCard(userId, packageName, cardLast4); + if (b != null) { + return AccountResolution( + accountId: b.accountId, score: 100, bindingId: b.id); + } + } + + if (phone != null) { + final b = await _bindings.findByPhone(userId, phone); + if (b != null) { + return AccountResolution( + accountId: b.accountId, score: 100, bindingId: b.id); + } + } + + final byPkg = await _bindings.findByPackageName(userId, packageName); + if (byPkg.length == 1) { + // Один счёт для этого банка — высокая уверенность (§8.2). + return AccountResolution( + accountId: byPkg.first.accountId, score: 75, bindingId: byPkg.first.id); + } + if (byPkg.length > 1) { + // Несколько счетов — эвристика «первый», низкая уверенность. + return AccountResolution( + accountId: byPkg.first.accountId, score: 45, bindingId: byPkg.first.id); + } + + return const AccountResolution(accountId: null, score: 15); + } +} diff --git a/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart b/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart new file mode 100644 index 0000000..1b218ed --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart @@ -0,0 +1,117 @@ +import '../../domain/entities/parse_draft.dart'; +import '../../domain/entities/parse_rule.dart'; +import '../../domain/entities/raw_message.dart'; +import '../../domain/entities/rule_candidate.dart'; + +/// Пять per-field оценок уверенности (0..100), сохраняются в `raw_messages`. +class FieldScores { + const FieldScores({ + required this.amount, + required this.account, + required this.type, + required this.merchant, + required this.category, + }); + + final int amount; + final int account; + final int type; + final int merchant; + final int category; + + /// Минимум по «прочим полям» — это и есть вход в gate (§8.7). + int get otherFieldsMin => + [amount, account, type].reduce((a, b) => a < b ? a : b); +} + +/// Считает 5 per-field оценок (§8.1–8.6). +/// +/// Confidence играет вспомогательную роль: судьбу мерчанта решает наличие +/// правила (§5), а эти оценки гейтят прочие поля и дают подсветку «?». +/// +/// В Phase 1 источник draft — только regex (AI нет), поэтому amount/type при +/// успешном шаблоне = 100; account приходит из resolver ([accountScore]). +FieldScores scoreDraft({ + required ParseDraft draft, + required RawMessage message, + required int accountScore, + ParseRule? merchantRule, + RuleCandidate? merchantCandidate, + RuleCandidate? categoryCandidate, +}) { + // Amount: regex выдал единственную сумму → 100 (§8.1). + var amount = 100; + + // Type: bank template явно указал тип → 100 (§8.3). + const type = 100; + + // Merchant (§8.4). + var merchant = _scoreMerchant(draft, merchantRule, merchantCandidate); + + // Category (§8.5). + var category = + _scoreCategory(draft, merchantRule, categoryCandidate); + + // ── Sanity checks / capping (§8.6) — обнуляют независимо от источника ── + final raw = draft.merchantRaw ?? ''; + if (raw.length < 5) merchant = _capAt(merchant, 30); + if (raw.isNotEmpty && RegExp(r'^[\d\s*+\-]+$').hasMatch(raw)) { + merchant = _capAt(merchant, 20); + } + if (draft.currency.isEmpty) amount = _capAt(amount, 40); + if (message.body.length < 20) { + amount = _capAt(amount, 50); + merchant = _capAt(merchant, 50); + category = _capAt(category, 50); + } + + // Жёсткое правило: категория не увереннее мерчанта (§8.5). + category = category > merchant ? merchant : category; + + return FieldScores( + amount: amount, + account: accountScore, + type: type, + merchant: merchant, + category: category, + ); +} + +int _scoreMerchant( + ParseDraft draft, + ParseRule? rule, + RuleCandidate? candidate, +) { + if (rule != null) return 100; // сработало активное правило + if (candidate != null) { + return candidate.seenCount >= 3 ? 80 : 60; + } + final raw = draft.merchantRaw ?? ''; + // «Прошёл sanity» здесь грубо: непустое осмысленное имя. + return raw.length >= 5 ? 40 : 15; +} + +int _scoreCategory( + ParseDraft draft, + ParseRule? rule, + RuleCandidate? candidate, +) { + if (rule?.categoryId != null) return 100; + if (candidate != null) { + return candidate.seenCount >= 3 ? 85 : 65; + } + // Новый мерчант без AI-подсказки (Phase 1) — слабо. + return 25; +} + +int _capAt(int value, int cap) => value > cap ? cap : value; + +/// Сообщение похоже на «не транзакцию» (баланс/реклама) — §8.6 последнее правило. +bool looksLikeNonTransaction(String body) { + final lower = body.toLowerCase(); + final hasBalanceWord = lower.contains('баланс') || lower.contains('доступно'); + final hasTxKeyword = RegExp( + r'покупка|оплата|списание|зачисление|пополнение|поступление|перевод|возврат') + .hasMatch(lower); + return hasBalanceWord && !hasTxKeyword; +} diff --git a/lib/src/features/notification_parsing/data/parser/decision_gate.dart b/lib/src/features/notification_parsing/data/parser/decision_gate.dart new file mode 100644 index 0000000..76470f5 --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/decision_gate.dart @@ -0,0 +1,35 @@ +import '../../domain/entities/parse_rule.dart'; +import 'confidence_scorer.dart'; + +/// Решение gate: молча в ленту или в Inbox. +enum GateDecision { autoApply, inbox } + +/// Очень крупная сумма (₽) — всегда Inbox, защита от галлюцинаций (§15). +/// Минорные единицы: 100 000 ₽ = 10 000 000 копеек. +const int _hugeAmountMinor = 100000 * 100; + +/// Decision gate (§8.7). +/// +/// Молча в ленту только если есть подтверждённое правило для мерчанта, +/// sanity пройден, и min(сумма, счёт, тип) ≥ порога строгости. +/// Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту. +GateDecision decide({ + required ParseRule? merchantRule, + required bool sanityPassed, + required FieldScores scores, + required int strictness, + required int amountMinor, + required bool hasAccount, +}) { + if (amountMinor > _hugeAmountMinor) return GateDecision.inbox; + + // Без разрешённого счёта нечем создать транзакцию молча. + if (!hasAccount) return GateDecision.inbox; + + if (merchantRule != null && + sanityPassed && + scores.otherFieldsMin >= strictness) { + return GateDecision.autoApply; + } + return GateDecision.inbox; +} diff --git a/lib/src/features/notification_parsing/data/parser/dedup.dart b/lib/src/features/notification_parsing/data/parser/dedup.dart new file mode 100644 index 0000000..e357360 --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/dedup.dart @@ -0,0 +1,28 @@ +/// Детерминированный хэш для дедупликации уведомлений (§15). +/// +/// `String.hashCode` в Dart рандомизируется на каждый запуск изолята, поэтому +/// для идемпотентности «переживающей» перезагрузку устройства используем +/// стабильный FNV-1a (64-bit) и кодируем в hex. Без внешних зависимостей. +/// +/// Хэш считается по `packageName + '\n' + body` — **без времени**, чтобы +/// повторная доставка того же сообщения схлопывалась в одну запись. +String computeDedupHash(String packageName, String body) { + final input = '$packageName\n$body'; + return _fnv1a64(input); +} + +const int _fnvOffset = 0xcbf29ce484222325; +const int _fnvPrime = 0x100000001b3; + +String _fnv1a64(String s) { + // BigInt-free 64-bit FNV-1a поверх unicode code units. Dart int — 64-bit + // на mobile/desktop; переполнение оборачивается, что для хэша приемлемо. + var hash = _fnvOffset; + final units = s.codeUnits; + for (var i = 0; i < units.length; i++) { + hash ^= units[i]; + hash *= _fnvPrime; + } + // Приводим к беззнаковому hex фиксированной ширины. + return hash.toUnsigned(64).toRadixString(16).padLeft(16, '0'); +} diff --git a/lib/src/features/notification_parsing/data/parser/draft_codec.dart b/lib/src/features/notification_parsing/data/parser/draft_codec.dart new file mode 100644 index 0000000..e70674e --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/draft_codec.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import '../../../../core/database/converters/enum_converters.dart'; +import '../../domain/entities/parse_draft.dart'; +import '../../domain/entities/rule_suggestion.dart'; +import '../../domain/enums.dart'; + +/// Сериализация связки `ParseDraft` + `RuleSuggestion` в строку для +/// `RawMessage.draftJson`. Проект не использует json_serializable — +/// кодируем вручную (см. CLAUDE.md). +/// +/// Связка кешируется при отправке сообщения в Inbox, чтобы экран мог +/// восстановить предложение без повторного парсинга. +class DraftBundle { + const DraftBundle({required this.draft, this.suggestion}); + + final ParseDraft draft; + final RuleSuggestion? suggestion; +} + +String encodeDraftBundle(ParseDraft draft, RuleSuggestion? suggestion) { + return jsonEncode({ + 'draft': _draftToJson(draft), + if (suggestion != null) 'suggestion': _suggestionToJson(suggestion), + }); +} + +DraftBundle? decodeDraftBundle(String? json) { + if (json == null || json.isEmpty) return null; + final map = jsonDecode(json) as Map; + final draftMap = map['draft'] as Map?; + if (draftMap == null) return null; + final suggMap = map['suggestion'] as Map?; + return DraftBundle( + draft: _draftFromJson(draftMap), + suggestion: suggMap == null ? null : _suggestionFromJson(suggMap), + ); +} + +Map _draftToJson(ParseDraft d) => { + 'rawMessageId': d.rawMessageId, + 'type': d.type.name, + 'amount': d.amount, + 'currency': d.currency, + 'cardLast4': d.cardLast4, + 'merchantRaw': d.merchantRaw, + 'counterpartyName': d.counterpartyName, + 'counterpartyPhone': d.counterpartyPhone, + 'dateTime': d.dateTime?.toIso8601String(), + 'kind': d.kind?.name, + 'categorySuggestion': d.categorySuggestion, + 'accountId': d.accountId, + 'merchantCanonical': d.merchantCanonical, + 'categoryId': d.categoryId, + 'transferToAccountId': d.transferToAccountId, + 'source': d.source.name, + }; + +ParseDraft _draftFromJson(Map m) => ParseDraft( + rawMessageId: m['rawMessageId'] as String, + type: TransactionType.values.byName(m['type'] as String), + amount: m['amount'] as int, + currency: (m['currency'] as String?) ?? 'RUB', + cardLast4: m['cardLast4'] as String?, + merchantRaw: m['merchantRaw'] as String?, + counterpartyName: m['counterpartyName'] as String?, + counterpartyPhone: m['counterpartyPhone'] as String?, + dateTime: m['dateTime'] == null + ? null + : DateTime.parse(m['dateTime'] as String), + kind: m['kind'] == null ? null : TxKind.values.byName(m['kind'] as String), + categorySuggestion: m['categorySuggestion'] as String?, + accountId: m['accountId'] as String?, + merchantCanonical: m['merchantCanonical'] as String?, + categoryId: m['categoryId'] as String?, + transferToAccountId: m['transferToAccountId'] as String?, + source: ParseSource.values.byName(m['source'] as String), + ); + +Map _suggestionToJson(RuleSuggestion s) => { + 'merchantRaw': s.merchantRaw, + 'merchantCanonical': s.merchantCanonical, + 'categoryId': s.categoryId, + 'categoryName': s.categoryName, + 'accountId': s.accountId, + }; + +RuleSuggestion _suggestionFromJson(Map m) => RuleSuggestion( + merchantRaw: m['merchantRaw'] as String, + merchantCanonical: m['merchantCanonical'] as String, + categoryId: m['categoryId'] as String?, + categoryName: m['categoryName'] as String?, + accountId: m['accountId'] as String?, + ); diff --git a/lib/src/features/notification_parsing/data/parser/merchant_normalizer.dart b/lib/src/features/notification_parsing/data/parser/merchant_normalizer.dart new file mode 100644 index 0000000..c7eee3c --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/merchant_normalizer.dart @@ -0,0 +1,17 @@ +/// Нормализует сырое имя мерчанта из уведомления в canonical-форму для +/// предложения правила. +/// +/// `«WBSPB*MOSCOW»` → `«WBSPB»`, `«PYATEROCHKA 2351»` → `«PYATEROCHKA»`. +/// Отрезает хвост после `*`, завершающие цифры и схлопывает пробелы. +String normalizeMerchant(String raw) { + var s = raw.trim(); + + final star = s.indexOf('*'); + if (star > 0) s = s.substring(0, star); + + s = s.replaceAll(RegExp(r'\s+'), ' ').trim(); + // Убираем завершающие цифры/коды торговой точки. + s = s.replaceAll(RegExp(r'[\s\d]+$'), '').trim(); + + return s.isEmpty ? raw.trim() : s; +} diff --git a/lib/src/features/notification_parsing/data/parser/regex_parser.dart b/lib/src/features/notification_parsing/data/parser/regex_parser.dart new file mode 100644 index 0000000..b71c64a --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/regex_parser.dart @@ -0,0 +1,95 @@ +import '../../../../core/database/converters/enum_converters.dart'; +import '../../domain/entities/bank_template.dart'; +import '../../domain/entities/parse_draft.dart'; +import '../../domain/entities/raw_message.dart'; +import '../../domain/enums.dart'; +import '../bank_templates/bank_templates_catalog.dart'; + +/// Этап 1 pipeline: разбор тела уведомления встроенными regex-шаблонами. +/// +/// Возвращает `null`, если ни один шаблон не совпал — тогда pipeline уходит +/// на AI fallback (Phase 2) или в `parsed_partial`. +class RegexParser { + const RegexParser([this.templates = bankTemplatesCatalog]); + + final List templates; + + ParseDraft? parse(RawMessage msg) { + for (final t in templates) { + if (!t.enabled) continue; + final re = RegExp(t.pattern, caseSensitive: false); + final m = re.firstMatch(msg.body); + if (m == null) continue; + + final amountStr = _group(t.extract['amount'], m); + final amount = amountStr == null ? null : parseAmountMinor(amountStr); + if (amount == null || amount <= 0) continue; + + return ParseDraft( + rawMessageId: msg.id, + type: t.type, + amount: amount, + currency: 'RUB', + cardLast4: _group(t.extract['cardLast4'], m), + merchantRaw: _group(t.extract['merchant'], m)?.trim(), + counterpartyPhone: _group(t.extract['phone'], m), + kind: _kindFor(t.type), + source: ParseSource.regex, + ); + } + return null; + } + + /// Извлекает значение по back-reference вида `$1` из совпадения. + String? _group(String? ref, RegExpMatch m) { + if (ref == null) return null; + final n = int.tryParse(ref.replaceAll(r'$', '')); + if (n == null || n > m.groupCount) return null; + final v = m.group(n); + return (v == null || v.isEmpty) ? null : v; + } + + TxKind _kindFor(TransactionType type) => switch (type) { + TransactionType.expense => TxKind.purchase, + TransactionType.income => TxKind.transferIn, + TransactionType.transfer => TxKind.transferOut, + }; +} + +/// Парсит денежную строку РФ-формата в минорные единицы (копейки). +/// +/// Поддерживает пробелы/неразрывные пробелы как разделители тысяч и +/// `,`/`.` как десятичный разделитель: `«1 240,50»` → 124050, `«1240»` → 124000. +int? parseAmountMinor(String raw) { + final s = raw.replaceAll(RegExp(r'\s'), ''); + if (s.isEmpty) return null; + + final lastDot = s.lastIndexOf('.'); + final lastComma = s.lastIndexOf(','); + final decPos = lastDot > lastComma ? lastDot : lastComma; + + String intPart; + String fracPart; + if (decPos >= 0) { + final after = s.substring(decPos + 1); + // Десятичный разделитель — только если 1–2 цифры в хвосте. + if (after.isNotEmpty && after.length <= 2 && _digitsOnly(after)) { + intPart = s.substring(0, decPos); + fracPart = after.padRight(2, '0'); + } else { + intPart = s; + fracPart = '00'; + } + } else { + intPart = s; + fracPart = '00'; + } + + intPart = intPart.replaceAll(RegExp(r'[^\d]'), ''); + if (intPart.isEmpty) return null; + + final minor = int.tryParse(intPart + fracPart.substring(0, 2)); + return minor; +} + +bool _digitsOnly(String s) => RegExp(r'^\d+$').hasMatch(s); diff --git a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart new file mode 100644 index 0000000..7735685 --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart @@ -0,0 +1,81 @@ +import '../../domain/entities/parse_rule.dart'; +import '../../domain/enums.dart'; + +/// Проверяет, совпадает ли правило с сообщением. +/// +/// `contains`/`regex` матчатся по полному телу уведомления (как в редакторе +/// «Если SMS содержит …» и live-превью); `exact` — по точному совпадению +/// нормализованного имени мерчанта. +bool ruleMatches(ParseRule rule, {required String body, String? merchantRaw}) => + patternMatches(rule.pattern, rule.matchMode, + body: body, merchantRaw: merchantRaw); + +/// Низкоуровневый матч паттерна (для live-превью в редакторе, где правила +/// ещё нет). +bool patternMatches( + String rawPattern, + MatchMode mode, { + required String body, + String? merchantRaw, +}) { + final pattern = rawPattern.trim(); + if (pattern.isEmpty) return false; + + switch (mode) { + case MatchMode.contains: + return body.toLowerCase().contains(pattern.toLowerCase()); + case MatchMode.exact: + final target = (merchantRaw ?? body).trim().toLowerCase(); + return target == pattern.toLowerCase(); + case MatchMode.regex: + try { + return RegExp(pattern, caseSensitive: false).hasMatch(body); + } on FormatException { + return false; + } + } +} + +/// ГЛАВНЫЙ gate (§5, шаг 4): ищет активное правило `merchantToCategory`, +/// которое уже подтверждено пользователем для этого мерчанта. +/// +/// При конфликте (§9.4): более длинный pattern → больший matchCount → +/// больший priority. +ParseRule? findMerchantRule( + List rules, { + required String body, + String? merchantRaw, +}) { + final matches = rules + .where((r) => + r.enabled && + r.kind == ParseRuleKind.merchantToCategory && + ruleMatches(r, body: body, merchantRaw: merchantRaw)) + .toList() + ..sort(_bySpecificity); + return matches.isEmpty ? null : matches.first; +} + +/// Ищет активное правило-исключение (kind=ignore) для сообщения. +ParseRule? findIgnoreRule( + List rules, { + required String body, + String? merchantRaw, +}) { + final matches = rules + .where((r) => + r.enabled && + r.kind == ParseRuleKind.ignore && + ruleMatches(r, body: body, merchantRaw: merchantRaw)) + .toList() + ..sort(_bySpecificity); + return matches.isEmpty ? null : matches.first; +} + +int _bySpecificity(ParseRule a, ParseRule b) { + final byLen = b.pattern.length.compareTo(a.pattern.length); + if (byLen != 0) return byLen; + final byCount = b.matchCount.compareTo(a.matchCount); + if (byCount != 0) return byCount; + return b.priority.compareTo(a.priority); +} diff --git a/lib/src/features/notification_parsing/data/parser/rule_suggester.dart b/lib/src/features/notification_parsing/data/parser/rule_suggester.dart new file mode 100644 index 0000000..c1c64ab --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/rule_suggester.dart @@ -0,0 +1,23 @@ +import '../../domain/entities/rule_candidate.dart'; +import '../../domain/entities/rule_suggestion.dart'; +import 'merchant_normalizer.dart'; + +/// Формирует предложение правила для Inbox из наблюдённого кандидата (§9.1). +/// +/// Не персистируется — кешируется в `RawMessage.draftJson`. Подставляет +/// canonical-имя мерчанта и наиболее вероятную категорию в кнопку +/// «Создать правило «merchant → category»». +/// +/// [categoryCandidate] — кандидат kind=merchantToCategory с тем же rawValue +/// (его `resolvedValue` = categoryId). [categoryName] заполняется выше +/// (контроллером) по справочнику категорий. +RuleSuggestion suggestRule({ + required String merchantRaw, + RuleCandidate? categoryCandidate, +}) { + return RuleSuggestion( + merchantRaw: merchantRaw, + merchantCanonical: normalizeMerchant(merchantRaw), + categoryId: categoryCandidate?.resolvedValue, + ); +} 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 new file mode 100644 index 0000000..63a2261 --- /dev/null +++ b/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart @@ -0,0 +1,87 @@ +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 create({ + required String userId, + String? packageName, + String? bankKey, + String? cardLast4, + String? phone, + required String accountId, + }) async { + final id = const Uuid().v4(); + 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 deleteById(String id) => _dao.deleteById(id); +} diff --git a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart new file mode 100644 index 0000000..1ebf463 --- /dev/null +++ b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart @@ -0,0 +1,89 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/parse_rule.dart'; +import '../../domain/enums.dart'; +import '../../domain/repositories/parse_rules_repository.dart'; +import '../drift/daos/parse_rules_dao.dart'; +import '../mappers/parse_rule_mapper.dart'; + +class ParseRulesRepositoryImpl implements ParseRulesRepository { + const ParseRulesRepositoryImpl(this._dao); + final ParseRulesDao _dao; + + @override + Stream> watchByUser(String userId) => _dao + .watchByUser(userId) + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future> getEnabledByUser(String userId) async => + (await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList(); + + @override + Future> getByUser(String userId) async => + (await _dao.getByUser(userId)).map((r) => r.toDomain()).toList(); + + @override + Future findById(String id) async => + (await _dao.findById(id))?.toDomain(); + + @override + Future create({ + required String userId, + required ParseRuleKind kind, + required MatchMode matchMode, + required String pattern, + int priority = 0, + String? merchantCanonical, + String? categoryId, + String? accountId, + }) async { + final id = const Uuid().v4(); + await _dao.insert( + ParseRulesTableCompanion.insert( + id: id, + userId: userId, + kind: kind, + pattern: pattern, + matchMode: Value(matchMode), + priority: Value(priority), + merchantCanonical: Value(merchantCanonical), + categoryId: Value(categoryId), + accountId: Value(accountId), + ), + ); + final row = await _dao.findById(id); + return row!.toDomain(); + } + + @override + Future update(ParseRule rule) => _dao.updateRow( + ParseRulesTableCompanion( + id: Value(rule.id), + kind: Value(rule.kind), + matchMode: Value(rule.matchMode), + pattern: Value(rule.pattern), + priority: Value(rule.priority), + merchantCanonical: Value(rule.merchantCanonical), + categoryId: Value(rule.categoryId), + accountId: Value(rule.accountId), + enabled: Value(rule.enabled), + ), + ); + + @override + Future setEnabled(String id, {required bool enabled}) => + _dao.setEnabled(id, enabled: enabled); + + @override + Future incrementMatchCount(String id, DateTime now) => + _dao.incrementMatchCount(id, now); + + @override + Future decrementWeight(String id) => _dao.decrementWeight(id); + + @override + Future deleteById(String id) => _dao.deleteById(id); +} diff --git a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart new file mode 100644 index 0000000..fd3dd3b --- /dev/null +++ b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart @@ -0,0 +1,105 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/raw_message.dart'; +import '../../domain/enums.dart'; +import '../../domain/repositories/raw_messages_repository.dart'; +import '../drift/daos/raw_messages_dao.dart'; +import '../mappers/raw_message_mapper.dart'; +import '../parser/dedup.dart'; + +class RawMessagesRepositoryImpl implements RawMessagesRepository { + const RawMessagesRepositoryImpl(this._dao); + final RawMessagesDao _dao; + + @override + Stream> watchPending(String userId) => _dao + .watchPending(userId) + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Stream> watchInbox(String userId) => _dao + .watchInbox(userId) + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Stream watchInboxCount(String userId) => _dao.watchInboxCount(userId); + + @override + Future findById(String id) async => + (await _dao.findById(id))?.toDomain(); + + @override + Future findByDedupHash(String userId, String dedupHash) async => + (await _dao.findByDedupHash(userId, dedupHash))?.toDomain(); + + @override + Future> recentByUser(String userId, DateTime since) async => + (await _dao.getRecentByUser(userId, since)) + .map((r) => r.toDomain()) + .toList(); + + @override + Future insertIncoming({ + required String userId, + required String packageName, + String? title, + required String body, + required DateTime receivedAt, + }) async { + final hash = computeDedupHash(packageName, body); + final existing = await _dao.findByDedupHash(userId, hash); + if (existing != null) return existing.toDomain(); + + final id = const Uuid().v4(); + await _dao.insert( + RawMessagesTableCompanion.insert( + id: id, + userId: userId, + packageName: packageName, + title: Value(title), + body: body, + receivedAt: receivedAt, + dedupHash: hash, + status: const Value(RawMessageStatus.pending), + ), + ); + final row = await _dao.findById(id); + return row!.toDomain(); + } + + @override + Future updateStatus(String id, RawMessageStatus status) => + _dao.updateStatus(id, status); + + @override + Future updateAfterParse({ + required String id, + required RawMessageStatus status, + String? draftJson, + int? confidenceAmount, + int? confidenceAccount, + int? confidenceType, + int? confidenceMerchant, + int? confidenceCategory, + }) => + _dao.updateAfterParse( + id: id, + status: status, + draftJson: draftJson, + confidenceAmount: confidenceAmount, + confidenceAccount: confidenceAccount, + confidenceType: confidenceType, + confidenceMerchant: confidenceMerchant, + confidenceCategory: confidenceCategory, + ); + + @override + Future linkTransaction(String id, String transactionId) => + _dao.linkTransaction(id, transactionId); + + @override + Future incrementParseAttempts(String id) => + _dao.incrementParseAttempts(id); +} diff --git a/lib/src/features/notification_parsing/data/repositories/rule_candidates_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/rule_candidates_repository_impl.dart new file mode 100644 index 0000000..c00e3ba --- /dev/null +++ b/lib/src/features/notification_parsing/data/repositories/rule_candidates_repository_impl.dart @@ -0,0 +1,60 @@ +import 'package:uuid/uuid.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/rule_candidate.dart'; +import '../../domain/enums.dart'; +import '../../domain/repositories/rule_candidates_repository.dart'; +import '../drift/daos/rule_candidates_dao.dart'; +import '../mappers/rule_candidate_mapper.dart'; + +class RuleCandidatesRepositoryImpl implements RuleCandidatesRepository { + const RuleCandidatesRepositoryImpl(this._dao); + final RuleCandidatesDao _dao; + + @override + Stream> watchByUser(String userId) => _dao + .watchByUser(userId) + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future findByRawValue( + String userId, + ParseRuleKind kind, + String rawValue, + ) async => + (await _dao.findByRawValue(userId, kind, rawValue))?.toDomain(); + + @override + Future observe({ + required String userId, + required ParseRuleKind kind, + required String rawValue, + required String resolvedValue, + }) async { + final now = DateTime.now(); + final existing = await _dao.findByRawValue(userId, kind, rawValue); + if (existing != null) { + await _dao.incrementSeen( + existing.id, + resolvedValue: resolvedValue, + now: now, + ); + return; + } + await _dao.insert( + RuleCandidatesTableCompanion.insert( + id: const Uuid().v4(), + userId: userId, + kind: kind, + rawValue: rawValue, + resolvedValue: resolvedValue, + firstSeenAt: now, + lastSeenAt: now, + ), + ); + } + + @override + Future deleteByRawValue(String userId, String rawValue) => + _dao.deleteByRawValue(userId, rawValue); +} diff --git a/lib/src/features/notification_parsing/domain/enums.dart b/lib/src/features/notification_parsing/domain/enums.dart index 46ad88c..69b0235 100644 --- a/lib/src/features/notification_parsing/domain/enums.dart +++ b/lib/src/features/notification_parsing/domain/enums.dart @@ -1,5 +1,6 @@ /// Enums for the notification-parsing feature. /// Drift converters are defined in the data layer; these are pure-Dart. +library; // --------------------------------------------------------------------------- // RawMessageStatus 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 new file mode 100644 index 0000000..91b3131 --- /dev/null +++ b/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart @@ -0,0 +1,40 @@ +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, + ); + + Future create({ + required String userId, + String? packageName, + String? bankKey, + String? cardLast4, + String? phone, + required String accountId, + }); + + Future incrementMatchCount(String id); + + Future deleteById(String id); +} diff --git a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart new file mode 100644 index 0000000..7d392bc --- /dev/null +++ b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart @@ -0,0 +1,39 @@ +import '../entities/parse_rule.dart'; +import '../enums.dart'; + +/// Доступ к пользовательским правилам парсинга ([ParseRule]). +abstract interface class ParseRulesRepository { + /// Все правила пользователя — для экрана «Правила парсинга». + Stream> watchByUser(String userId); + + /// Только активные правила — для pipeline (rule_lookup). + Future> getEnabledByUser(String userId); + + Future> getByUser(String userId); + + Future findById(String id); + + /// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность. + Future create({ + required String userId, + required ParseRuleKind kind, + required MatchMode matchMode, + required String pattern, + int priority, + String? merchantCanonical, + String? categoryId, + String? accountId, + }); + + Future update(ParseRule rule); + + Future setEnabled(String id, {required bool enabled}); + + /// Инкремент matchCount + lastMatchAt при срабатывании. + Future incrementMatchCount(String id, DateTime now); + + /// Откат («правило сработало неправильно»): weight-- и деактивация при <=0. + Future decrementWeight(String id); + + Future deleteById(String id); +} diff --git a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart new file mode 100644 index 0000000..6b9da3c --- /dev/null +++ b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart @@ -0,0 +1,54 @@ +import '../entities/raw_message.dart'; +import '../enums.dart'; + +/// Доступ к сырым входящим уведомлениям ([RawMessage]). +/// +/// Точка входа pipeline: сообщения создаются со статусом +/// [RawMessageStatus.pending], воркер их разбирает и переводит в +/// `applied` / `inbox` / `ignored` / `failed`. +abstract interface class RawMessagesRepository { + /// Необработанные сообщения — источник для ParsingWorker. + Stream> watchPending(String userId); + + /// Сообщения, ожидающие подтверждения в Inbox. + Stream> watchInbox(String userId); + + /// Реактивный счётчик Inbox — для бэджа на Home. + Stream watchInboxCount(String userId); + + Future findById(String id); + + Future findByDedupHash(String userId, String dedupHash); + + /// Сообщения за период (для live-превью правил). + Future> recentByUser(String userId, DateTime since); + + /// Идемпотентная вставка нового уведомления. Если сообщение с тем же + /// [dedupHash] уже есть — возвращает существующее, не создавая дубль. + Future insertIncoming({ + required String userId, + required String packageName, + String? title, + required String body, + required DateTime receivedAt, + }); + + Future updateStatus(String id, RawMessageStatus status); + + /// Запись результатов парсинга (draft + per-field confidence). + Future updateAfterParse({ + required String id, + required RawMessageStatus status, + String? draftJson, + int? confidenceAmount, + int? confidenceAccount, + int? confidenceType, + int? confidenceMerchant, + int? confidenceCategory, + }); + + /// Привязка к созданной транзакции (status → applied). + Future linkTransaction(String id, String transactionId); + + Future incrementParseAttempts(String id); +} diff --git a/lib/src/features/notification_parsing/domain/repositories/rule_candidates_repository.dart b/lib/src/features/notification_parsing/domain/repositories/rule_candidates_repository.dart new file mode 100644 index 0000000..5b898cc --- /dev/null +++ b/lib/src/features/notification_parsing/domain/repositories/rule_candidates_repository.dart @@ -0,0 +1,26 @@ +import '../entities/rule_candidate.dart'; +import '../enums.dart'; + +/// Доступ к накопленным наблюдениям ([RuleCandidate]) — источнику +/// предложений правил для Inbox. +abstract interface class RuleCandidatesRepository { + Stream> watchByUser(String userId); + + Future findByRawValue( + String userId, + ParseRuleKind kind, + String rawValue, + ); + + /// Наблюдение: создаёт кандидата или инкрементит seenCount существующего, + /// обновляя resolvedValue и lastSeenAt. + Future observe({ + required String userId, + required ParseRuleKind kind, + required String rawValue, + required String resolvedValue, + }); + + /// Удаляет всех кандидатов для rawValue (при создании правила). + Future deleteByRawValue(String userId, String rawValue); +} diff --git a/lib/src/features/notification_parsing/presentation/screens/debug_inject_screen.dart b/lib/src/features/notification_parsing/presentation/screens/debug_inject_screen.dart new file mode 100644 index 0000000..106fcc4 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/debug_inject_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../app/l10n/l10n.dart'; +import '../../../../app/theme/app_colors.dart'; +import '../../../user/application/active_user_controller.dart'; +import '../../application/notification_parsing_providers.dart'; + +/// Debug-инжектор сырых уведомлений (только в debug-сборке). +/// +/// Заменяет нативный `NotificationListenerService` (Phase 0) для прогона +/// pipeline на эмуляторе: создаёт `raw_messages` со статусом pending, дальше +/// их подхватывает [ParsingWorker]. +class DebugInjectScreen extends ConsumerStatefulWidget { + const DebugInjectScreen({super.key}); + + @override + ConsumerState createState() => _DebugInjectScreenState(); +} + +class _DebugInjectScreenState extends ConsumerState { + final _packageCtrl = TextEditingController(text: 'ru.sberbankmobile'); + final _titleCtrl = TextEditingController(); + final _bodyCtrl = TextEditingController(); + + @override + void dispose() { + _packageCtrl.dispose(); + _titleCtrl.dispose(); + _bodyCtrl.dispose(); + super.dispose(); + } + + void _fillSample() { + _packageCtrl.text = 'ru.sberbankmobile'; + _bodyCtrl.text = + 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA 14:05 Баланс: 5000 ₽'; + setState(() {}); + } + + Future _send() async { + final userId = ref.read(activeUserControllerProvider).value?.id; + final body = _bodyCtrl.text.trim(); + if (userId == null || body.isEmpty) return; + + await ref.read(rawMessagesRepositoryProvider).insertIncoming( + userId: userId, + packageName: _packageCtrl.text.trim(), + title: _titleCtrl.text.trim().isEmpty ? null : _titleCtrl.text.trim(), + body: body, + receivedAt: DateTime.now(), + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.debugInjectSent)), + ); + _bodyCtrl.clear(); + _titleCtrl.clear(); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(l10n.debugInjectTitle), + actions: [ + TextButton( + onPressed: _fillSample, + child: Text(l10n.debugInjectFillSample), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _Field(label: l10n.debugInjectPackageLabel, controller: _packageCtrl), + const SizedBox(height: 12), + _Field(label: l10n.debugInjectTitleLabel, controller: _titleCtrl), + const SizedBox(height: 12), + _Field( + label: l10n.debugInjectBodyLabel, + controller: _bodyCtrl, + maxLines: 4, + ), + const SizedBox(height: 20), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: p.accent, + minimumSize: const Size.fromHeight(48), + ), + onPressed: _send, + child: Text(l10n.debugInjectSend), + ), + ], + ), + ); + } +} + +class _Field extends StatelessWidget { + const _Field({ + required this.label, + required this.controller, + this.maxLines = 1, + }); + + final String label; + final TextEditingController controller; + final int maxLines; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 6), + TextField( + controller: controller, + maxLines: maxLines, + decoration: InputDecoration( + filled: true, + fillColor: p.paper2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: p.line), + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart new file mode 100644 index 0000000..79401c3 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart @@ -0,0 +1,114 @@ +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 '../../../categories/application/categories_controller.dart'; +import '../../../categories/domain/entities/category.dart'; +import '../../../user/application/active_user_controller.dart'; +import '../../application/inbox_controller.dart'; +import '../../domain/entities/raw_message.dart'; +import '../widgets/inbox_card.dart'; + +/// Inbox: сообщения, ожидающие подтверждения (§12.2). +class InboxScreen extends ConsumerWidget { + const InboxScreen({super.key}); + + @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 messages = + ref.watch(inboxMessagesProvider(userId)).value ?? const []; + final categories = + ref.watch(categoriesStreamProvider(userId)).value ?? const []; + final categoryById = {for (final c in categories) c.id: c}; + final defaultAccountId = ref.watch(defaultAccountProvider(userId)).value?.id; + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text( + messages.isEmpty + ? l10n.inboxTitle + : '${l10n.inboxTitle} (${messages.length})', + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + l10n.inboxSubtitle, + style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3), + ), + ), + Expanded( + child: messages.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + l10n.inboxEmpty, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: p.ink2), + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.only(bottom: 12), + itemCount: messages.length, + itemBuilder: (context, i) => InboxCard( + message: messages[i], + userId: userId, + defaultAccountId: defaultAccountId, + categoryById: categoryById, + ), + ), + ), + if (messages.isNotEmpty) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Row( + children: [ + Expanded( + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: p.accent), + onPressed: () => ref + .read(inboxControllerProvider.notifier) + .applyAll(userId), + child: Text(l10n.inboxApplyAll), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: p.ink, + side: BorderSide(color: p.line), + ), + onPressed: () => ref + .read(inboxControllerProvider.notifier) + .hideAll(userId), + child: Text(l10n.inboxHideAll), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart new file mode 100644 index 0000000..c1fc4d5 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/foundation.dart'; +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 '../../../user/application/active_user_controller.dart'; +import '../../application/parsing_settings_controller.dart'; +import '../../application/rules_controller.dart'; +import '../../domain/entities/parse_rule.dart'; + +/// Экран настроек парсинга (§12.5). Phase 1: тумблер фичи + строгость +/// авто-добавления + вход в правила. AI-секция — заглушка под Phase 2. +class ParsingSettingsScreen extends ConsumerWidget { + const ParsingSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final l10n = context.l10n; + final userId = ref.watch(activeUserControllerProvider).value?.id; + final settings = ref.watch(parsingSettingsControllerProvider).value; + final controller = ref.read(parsingSettingsControllerProvider.notifier); + + final rulesCount = userId == null + ? 0 + : (ref.watch(parseRulesListProvider(userId)).value ?? + const []) + .length; + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(l10n.parsingSettingsTitle), + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + _Card( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + title: Text(l10n.parsingEnableLabel, + style: TextStyle(fontSize: 14, color: p.ink)), + value: settings?.enabled ?? true, + activeThumbColor: p.accent, + onChanged: settings == null + ? null + : (v) => controller.setEnabled(v), + ), + ], + ), + const SizedBox(height: 16), + Text(l10n.parsingStrictnessLabel, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + _StrictnessSelector( + value: settings?.strictness ?? 85, + onChanged: (v) => controller.setStrictness(v), + ), + const SizedBox(height: 6), + Text(l10n.parsingStrictnessHint, + style: TextStyle(fontSize: 12, color: p.ink2)), + const SizedBox(height: 16), + _Card( + children: [ + _NavTile( + icon: Icons.rule_outlined, + title: l10n.parsingRulesTile, + onTap: () => context.push(AppRoutes.parsingRules), + ), + ], + ), + const SizedBox(height: 16), + Text(l10n.parsingAiSectionTitle, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + _Card( + children: [ + ListTile( + leading: Icon(Icons.smart_toy_outlined, color: p.line2), + title: Text(l10n.parsingAiComingSoon, + style: TextStyle(fontSize: 13, color: p.ink2)), + enabled: false, + ), + ], + ), + const SizedBox(height: 16), + Text(l10n.parsingRulesCreatedCount(rulesCount), + style: TextStyle(fontSize: 12, color: p.ink2)), + if (kDebugMode) ...[ + const SizedBox(height: 16), + Text(l10n.parsingDebugSectionTitle, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + _Card( + children: [ + _NavTile( + icon: Icons.bug_report_outlined, + title: l10n.parsingDebugInjectTile, + onTap: () => context.push(AppRoutes.parsingDebugInject), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _StrictnessSelector extends StatelessWidget { + const _StrictnessSelector({required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return SegmentedButton( + segments: [ + ButtonSegment(value: 75, label: Text(l10n.parsingStrictnessSoft)), + ButtonSegment(value: 85, label: Text(l10n.parsingStrictnessNormal)), + ButtonSegment(value: 95, label: Text(l10n.parsingStrictnessStrict)), + ], + selected: {value}, + showSelectedIcon: false, + onSelectionChanged: (s) => onChanged(s.first), + ); + } +} + +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), + ); + } +} + +class _NavTile extends StatelessWidget { + const _NavTile({ + 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, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + Icon(icon, size: 20, color: p.ink2), + const SizedBox(width: 12), + Expanded( + child: Text(title, style: TextStyle(fontSize: 14, color: p.ink)), + ), + Icon(Icons.chevron_right, size: 18, color: p.ink2), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 0000000..82cad8d --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart @@ -0,0 +1,497 @@ +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/theme/app_colors.dart'; +import '../../../../core/database/converters/enum_converters.dart'; +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 '../../../transactions/presentation/widgets/account_picker_sheet.dart'; +import '../../../transactions/presentation/widgets/category_picker_sheet.dart'; +import '../../../user/application/active_user_controller.dart'; +import '../../application/rules_controller.dart'; +import '../../domain/entities/parse_rule.dart'; +import '../../domain/enums.dart'; + +/// Предзаполнение редактора при создании правила из Inbox. +class RuleEditorPrefill { + const RuleEditorPrefill({ + required this.pattern, + required this.merchantCanonical, + this.categoryId, + this.accountId, + this.matchMode = MatchMode.contains, + }); + + final String pattern; + final String merchantCanonical; + final String? categoryId; + final String? accountId; + final MatchMode matchMode; +} + +/// Результат редактора в режиме compose (возвращается через pop в Inbox). +class RuleEditorResult { + const RuleEditorResult({ + required this.matchMode, + required this.pattern, + required this.merchantCanonical, + this.categoryId, + this.accountId, + this.priority = 0, + }); + + final MatchMode matchMode; + final String pattern; + final String merchantCanonical; + final String? categoryId; + final String? accountId; + final int priority; +} + +/// Редактор правила (§9.3). Два режима: +/// - [ruleId] != null — правка существующего правила (пишет в БД, есть «Удалить»); +/// - иначе — compose из Inbox: возвращает [RuleEditorResult] через `pop`. +class RuleEditorScreen extends ConsumerStatefulWidget { + const RuleEditorScreen({super.key, this.ruleId, this.prefill}); + + final String? ruleId; + final RuleEditorPrefill? prefill; + + @override + ConsumerState createState() => _RuleEditorScreenState(); +} + +class _RuleEditorScreenState extends ConsumerState { + final _patternCtrl = TextEditingController(); + final _merchantCtrl = TextEditingController(); + MatchMode _matchMode = MatchMode.contains; + String? _categoryId; + String? _accountId; + int _priority = 0; + bool _enabled = true; + bool _advanced = false; + bool _initialized = false; + + bool get _isEdit => widget.ruleId != null; + + @override + void initState() { + super.initState(); + final pf = widget.prefill; + if (pf != null) { + _patternCtrl.text = pf.pattern; + _merchantCtrl.text = pf.merchantCanonical; + _matchMode = pf.matchMode; + _categoryId = pf.categoryId; + _accountId = pf.accountId; + _initialized = true; + } + } + + @override + void dispose() { + _patternCtrl.dispose(); + _merchantCtrl.dispose(); + super.dispose(); + } + + void _hydrateFromRule(ParseRule rule) { + if (_initialized) return; + _patternCtrl.text = rule.pattern; + _merchantCtrl.text = rule.merchantCanonical ?? ''; + _matchMode = rule.matchMode; + _categoryId = rule.categoryId; + _accountId = rule.accountId; + _priority = rule.priority; + _enabled = rule.enabled; + _initialized = true; + } + + Future _save(String userId, ParseRule? existing) async { + final pattern = _patternCtrl.text.trim(); + if (pattern.isEmpty) return; + final merchant = _merchantCtrl.text.trim(); + + if (_isEdit && existing != null) { + await ref.read(rulesControllerProvider.notifier).update( + existing.copyWith( + matchMode: _matchMode, + pattern: pattern, + merchantCanonical: merchant.isEmpty ? null : merchant, + categoryId: _categoryId, + accountId: _accountId, + priority: _priority, + enabled: _enabled, + ), + ); + if (mounted) context.pop(); + } else { + context.pop( + RuleEditorResult( + matchMode: _matchMode, + pattern: pattern, + merchantCanonical: merchant, + categoryId: _categoryId, + accountId: _accountId, + priority: _priority, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final userId = ref.watch(activeUserControllerProvider).value?.id; + + if (userId == null) { + return const Scaffold(body: SizedBox.shrink()); + } + + ParseRule? existing; + if (_isEdit) { + existing = ref.watch(parseRuleByIdProvider(widget.ruleId!)).value; + if (existing != null) _hydrateFromRule(existing); + } + + final categories = + ref.watch(categoriesStreamProvider(userId)).value ?? const []; + final categoryName = _categoryId == null + ? null + : categories.where((c) => c.id == _categoryId).firstOrNull?.name; + final accounts = + ref.watch(accountsStreamProvider(userId)).value ?? const []; + final accountName = _accountId == null + ? null + : accounts.where((a) => a.id == _accountId).firstOrNull?.name; + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(_isEdit ? l10n.ruleEditorEditTitle : l10n.ruleEditorNewTitle), + actions: [ + if (_isEdit) + IconButton( + icon: Icon(Icons.delete_outline, color: p.negative), + onPressed: () async { + await ref + .read(rulesControllerProvider.notifier) + .delete(widget.ruleId!); + if (context.mounted) context.pop(); + }, + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + Text(l10n.ruleEditorIfContains, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 6), + TextField( + controller: _patternCtrl, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: l10n.ruleEditorPatternHint, + filled: true, + fillColor: p.paper2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: p.line), + ), + ), + ), + const SizedBox(height: 10), + _MatchModeSelector( + mode: _matchMode, + onChanged: (m) => setState(() => _matchMode = m), + ), + const SizedBox(height: 18), + Text(l10n.ruleEditorThen, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + _FieldRow( + label: l10n.ruleEditorMerchant, + child: TextField( + controller: _merchantCtrl, + textAlign: TextAlign.end, + decoration: InputDecoration( + hintText: l10n.ruleEditorMerchantHint, + isDense: true, + border: InputBorder.none, + ), + ), + ), + _Divider(color: p.line), + _PickerRow( + label: l10n.ruleEditorCategory, + value: categoryName ?? l10n.inboxNoCategory, + onTap: () async { + final id = await showCategoryPicker( + context, + userId: userId, + type: CategoryType.expense, + currentCategoryId: _categoryId, + ); + if (id != null) setState(() => _categoryId = id); + }, + ), + _Divider(color: p.line), + _PickerRow( + label: l10n.ruleEditorAccount, + value: accountName ?? l10n.ruleEditorAccountUnchanged, + onTap: () async { + final id = await showAccountPicker( + context, + userId: userId, + currentAccountId: _accountId, + ); + if (id != null) setState(() => _accountId = id); + }, + ), + const SizedBox(height: 12), + _AdvancedToggle( + expanded: _advanced, + onTap: () => setState(() => _advanced = !_advanced), + label: l10n.ruleEditorAdvanced, + ), + if (_advanced) ...[ + const SizedBox(height: 8), + _FieldRow( + label: l10n.ruleEditorPriority, + child: DropdownButton( + value: _priority, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem(value: 0, child: Text('0')), + DropdownMenuItem(value: 1, child: Text('+1')), + DropdownMenuItem(value: 2, child: Text('+2')), + ], + onChanged: (v) => setState(() => _priority = v ?? 0), + ), + ), + if (_isEdit) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.ruleEditorEnabledLabel, + style: TextStyle(fontSize: 14, color: p.ink)), + value: _enabled, + activeThumbColor: p.accent, + onChanged: (v) => setState(() => _enabled = v), + ), + ], + const SizedBox(height: 18), + _MatchesPreview( + userId: userId, + pattern: _patternCtrl.text, + mode: _matchMode, + ), + const SizedBox(height: 18), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: p.accent, + minimumSize: const Size.fromHeight(48), + ), + onPressed: _patternCtrl.text.trim().isEmpty + ? null + : () => _save(userId, existing), + child: Text(l10n.ruleEditorSave), + ), + ], + ), + ); + } +} + +class _MatchesPreview extends ConsumerWidget { + const _MatchesPreview({ + required this.userId, + required this.pattern, + required this.mode, + }); + + final String userId; + final String pattern; + final MatchMode mode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final l10n = context.l10n; + if (pattern.trim().isEmpty) return const SizedBox.shrink(); + final matches = + ref.watch(ruleMatchPreviewProvider(userId, pattern, mode)).value ?? + const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.ruleEditorMatchesTitle, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + if (matches.isEmpty) + Text(l10n.ruleEditorNoMatches, + style: TextStyle(fontSize: 13, color: p.ink2)) + else + ...matches.take(30).map( + (m) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(Icons.check, size: 14, color: p.positive), + const SizedBox(width: 8), + Expanded( + child: Text( + m.body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: p.ink2), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _MatchModeSelector extends StatelessWidget { + const _MatchModeSelector({required this.mode, required this.onChanged}); + + final MatchMode mode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final p = context.palette; + final items = <(MatchMode, String)>[ + (MatchMode.contains, l10n.ruleEditorModeContains), + (MatchMode.exact, l10n.ruleEditorModeExact), + (MatchMode.regex, l10n.ruleEditorModeRegex), + ]; + return Row( + children: [ + for (final (m, label) in items) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(label), + selected: mode == m, + selectedColor: p.accentSoft, + onSelected: (_) => onChanged(m), + ), + ), + ], + ); + } +} + +class _FieldRow extends StatelessWidget { + const _FieldRow({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 90, + child: Text(label, style: TextStyle(fontSize: 14, color: p.ink2)), + ), + Expanded(child: child), + ], + ), + ); + } +} + +class _PickerRow extends StatelessWidget { + const _PickerRow({ + required this.label, + required this.value, + required this.onTap, + }); + + final String label; + final String value; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + SizedBox( + width: 90, + child: Text(label, style: TextStyle(fontSize: 14, color: p.ink2)), + ), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: TextStyle(fontSize: 14, color: p.ink), + ), + ), + Icon(Icons.chevron_right, size: 18, color: p.ink2), + ], + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider({required this.color}); + final Color color; + @override + Widget build(BuildContext context) => Container(height: 1, color: color); +} + +class _AdvancedToggle extends StatelessWidget { + const _AdvancedToggle({ + required this.expanded, + required this.onTap, + required this.label, + }); + + final bool expanded; + final VoidCallback onTap; + final String label; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(expanded ? Icons.expand_less : Icons.expand_more, + size: 18, color: p.ink2), + const SizedBox(width: 6), + Text(label, style: TextStyle(fontSize: 14, color: p.ink2)), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart new file mode 100644 index 0000000..a8e7030 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart @@ -0,0 +1,139 @@ +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 '../../../categories/application/categories_controller.dart'; +import '../../../categories/domain/entities/category.dart'; +import '../../../user/application/active_user_controller.dart'; +import '../../application/rules_controller.dart'; +import '../../domain/entities/parse_rule.dart'; +import '../../domain/enums.dart'; +import '../widgets/rule_card.dart'; +import 'rule_editor_screen.dart'; + +enum _RuleFilter { all, merchants, accounts, ignore } + +/// Список правил парсинга (§12.3). +class RulesListScreen extends ConsumerStatefulWidget { + const RulesListScreen({super.key}); + + @override + ConsumerState createState() => _RulesListScreenState(); +} + +class _RulesListScreenState extends ConsumerState { + _RuleFilter _filter = _RuleFilter.all; + + bool _matches(ParseRule r) => switch (_filter) { + _RuleFilter.all => true, + _RuleFilter.merchants => r.kind == ParseRuleKind.merchantToCategory, + _RuleFilter.accounts => r.kind == ParseRuleKind.senderToAccount, + _RuleFilter.ignore => r.kind == ParseRuleKind.ignore, + }; + + @override + Widget build(BuildContext context) { + 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 rules = + (ref.watch(parseRulesListProvider(userId)).value ?? const []) + .where(_matches) + .toList(); + final categories = + ref.watch(categoriesStreamProvider(userId)).value ?? const []; + final categoryById = {for (final c in categories) c.id: c}; + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(l10n.rulesTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _createRule(context, userId), + ), + ], + ), + body: Column( + children: [ + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + children: [ + for (final entry in <(_RuleFilter, String)>[ + (_RuleFilter.all, l10n.rulesFilterAll), + (_RuleFilter.merchants, l10n.rulesFilterMerchants), + (_RuleFilter.accounts, l10n.rulesFilterAccounts), + (_RuleFilter.ignore, l10n.rulesFilterIgnore), + ]) + Padding( + padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8), + child: ChoiceChip( + label: Text(entry.$2), + selected: _filter == entry.$1, + selectedColor: p.accentSoft, + onSelected: (_) => setState(() => _filter = entry.$1), + ), + ), + ], + ), + ), + Expanded( + child: rules.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text(l10n.rulesEmpty, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: p.ink2)), + ), + ) + : ListView.separated( + itemCount: rules.length, + separatorBuilder: (_, _) => + Container(height: 1, color: p.line), + itemBuilder: (context, i) => RuleCard( + rule: rules[i], + categoryById: categoryById, + onTap: () => + context.push(AppRoutes.parsingRuleEdit(rules[i].id)), + onToggle: (enabled) => ref + .read(rulesControllerProvider.notifier) + .setEnabled(rules[i].id, enabled: enabled), + ), + ), + ), + ], + ), + ); + } + + Future _createRule(BuildContext context, String userId) async { + final result = + await context.push(AppRoutes.parsingRuleNew); + if (result == null) return; + await ref.read(rulesControllerProvider.notifier).create( + userId: userId, + kind: ParseRuleKind.merchantToCategory, + matchMode: result.matchMode, + pattern: result.pattern, + priority: result.priority, + merchantCanonical: + result.merchantCanonical.isEmpty ? null : result.merchantCanonical, + categoryId: result.categoryId, + accountId: result.accountId, + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/widgets/confidence_badge.dart b/lib/src/features/notification_parsing/presentation/widgets/confidence_badge.dart new file mode 100644 index 0000000..7c20fa5 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/widgets/confidence_badge.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_colors.dart'; + +/// Поле считается слабым, если оценка известна и ниже порога. +/// (Проценты пользователю не показываем — только подсветку «?».) +bool isWeakScore(int? score) => score != null && score < 60; + +/// Маленький бэдж «?» рядом со слабым полем в Inbox (§8.2, §12.2). +class ConfidenceBadge extends StatelessWidget { + const ConfidenceBadge({super.key, this.size = 16}); + + final double size; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: p.negative.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Text( + '?', + style: TextStyle( + fontSize: size * 0.7, + height: 1, + fontWeight: FontWeight.w700, + color: p.negative, + ), + ), + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart new file mode 100644 index 0000000..9a623e2 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart @@ -0,0 +1,389 @@ +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 '../../../../core/database/converters/enum_converters.dart'; +import '../../../categories/domain/entities/category.dart'; +import '../../../home/presentation/widgets/money_text.dart'; +import '../../application/inbox_controller.dart'; +import '../../data/parser/draft_codec.dart'; +import '../../domain/entities/raw_message.dart'; +import '../screens/rule_editor_screen.dart'; +import 'confidence_badge.dart'; + +/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три +/// действия — «Создать правило», «Подтвердить разово», «Игнорировать». +class InboxCard extends ConsumerWidget { + const InboxCard({ + super.key, + required this.message, + required this.userId, + required this.defaultAccountId, + required this.categoryById, + }); + + final RawMessage message; + final String userId; + final String? defaultAccountId; + final Map categoryById; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final bundle = decodeDraftBundle(message.draftJson); + + return Container( + margin: const EdgeInsets.fromLTRB(12, 6, 12, 6), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: p.line), + ), + child: bundle == null + ? _UnrecognizedBody(message: message) + : _RecognizedBody( + message: message, + userId: userId, + defaultAccountId: defaultAccountId, + categoryById: categoryById, + bundle: bundle, + ), + ); + } +} + +class _RecognizedBody extends ConsumerWidget { + const _RecognizedBody({ + required this.message, + required this.userId, + required this.defaultAccountId, + required this.categoryById, + required this.bundle, + }); + + final RawMessage message; + final String userId; + final String? defaultAccountId; + final Map categoryById; + final DraftBundle bundle; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final l10n = context.l10n; + final draft = bundle.draft; + final sugg = bundle.suggestion; + + final merchant = sugg?.merchantCanonical ?? + draft.merchantCanonical ?? + draft.merchantRaw ?? + '—'; + final categoryId = draft.categoryId ?? sugg?.categoryId; + final categoryName = + categoryId != null ? categoryById[categoryId]?.name : null; + final accountId = draft.accountId ?? defaultAccountId; + + final signed = + draft.type == TransactionType.expense ? -draft.amount : draft.amount; + final amountColor = + draft.type == TransactionType.expense ? p.negative : p.positive; + + final weakOther = isWeakScore(message.confidenceAmount) || + isWeakScore(message.confidenceAccount) || + isWeakScore(message.confidenceType); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + merchant, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: p.ink, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + MoneyText(signed, + color: amountColor, fontSize: 15, fontWeight: FontWeight.w700, + withSign: true), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + if (weakOther) ...[ + const ConfidenceBadge(), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + [ + categoryName ?? l10n.inboxNoCategory, + if (draft.cardLast4 != null) '· *${draft.cardLast4}', + ].join(' '), + style: TextStyle(fontSize: 13, color: p.ink2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + message.body, + style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + _CreateRuleButton( + label: categoryName != null + ? l10n.inboxCreateRule(merchant, categoryName) + : l10n.inboxCreateRuleNoCategory(merchant), + onTap: () => _createRule( + context, + ref, + accountId: accountId, + categoryId: categoryId, + merchant: merchant, + ), + onEdit: () => _openEditor( + context, + ref, + accountId: accountId, + categoryId: categoryId, + merchant: merchant, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _SecondaryButton( + icon: Icons.check, + label: l10n.inboxConfirmOnce, + onTap: accountId == null + ? null + : () => ref.read(inboxControllerProvider.notifier).confirmOnce( + userId: userId, + message: message, + draft: draft, + accountId: accountId, + categoryId: categoryId, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SecondaryButton( + icon: Icons.close, + label: l10n.inboxIgnore, + onTap: () => + ref.read(inboxControllerProvider.notifier).ignore(message), + ), + ), + ], + ), + ], + ); + } + + Future _createRule( + BuildContext context, + WidgetRef ref, { + required String? accountId, + required String? categoryId, + required String merchant, + }) async { + if (accountId == null) return; + final draft = bundle.draft; + if (categoryId == null) { + await _openEditor(context, ref, + accountId: accountId, categoryId: categoryId, merchant: merchant); + return; + } + await ref.read(inboxControllerProvider.notifier).createRule( + userId: userId, + message: message, + draft: draft, + accountId: accountId, + categoryId: categoryId, + merchantCanonical: merchant, + pattern: draft.merchantRaw ?? merchant, + ); + } + + Future _openEditor( + BuildContext context, + WidgetRef ref, { + required String? accountId, + required String? categoryId, + required String merchant, + }) async { + final draft = bundle.draft; + final result = await context.push( + AppRoutes.parsingRuleNew, + extra: RuleEditorPrefill( + pattern: draft.merchantRaw ?? merchant, + merchantCanonical: merchant, + categoryId: categoryId, + accountId: accountId, + ), + ); + if (result == null) return; + final acc = result.accountId ?? accountId; + if (acc == null) return; + await ref.read(inboxControllerProvider.notifier).createRule( + userId: userId, + message: message, + draft: draft, + accountId: acc, + categoryId: result.categoryId, + merchantCanonical: + result.merchantCanonical.isEmpty ? merchant : result.merchantCanonical, + pattern: result.pattern, + matchMode: result.matchMode, + ); + } +} + +class _UnrecognizedBody extends ConsumerWidget { + const _UnrecognizedBody({required this.message}); + + final RawMessage message; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.inboxUnrecognized, + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w600, color: p.ink)), + const SizedBox(height: 6), + 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.edit_outlined, + label: l10n.inboxAddManually, + onTap: () => context.push(AppRoutes.transactionNew), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SecondaryButton( + icon: Icons.close, + label: l10n.inboxIgnore, + onTap: () => + ref.read(inboxControllerProvider.notifier).ignore(message), + ), + ), + ], + ), + ], + ); + } +} + +class _CreateRuleButton extends StatelessWidget { + const _CreateRuleButton({ + required this.label, + required this.onTap, + required this.onEdit, + }); + + final String label; + final VoidCallback onTap; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + return Material( + color: p.accent, + borderRadius: BorderRadius.circular(12), + child: Row( + children: [ + Expanded( + child: InkWell( + borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Container(width: 1, height: 28, color: Colors.white24), + InkWell( + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + onTap: onEdit, + child: Tooltip( + message: l10n.inboxEditRuleTooltip, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Icon(Icons.edit_outlined, size: 18, color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} + +class _SecondaryButton extends StatelessWidget { + const _SecondaryButton({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return OutlinedButton.icon( + onPressed: onTap, + style: OutlinedButton.styleFrom( + foregroundColor: p.ink, + side: BorderSide(color: p.line), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + icon: Icon(icon, size: 16), + label: Text(label, + style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis), + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart new file mode 100644 index 0000000..075931f --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/l10n/l10n.dart'; +import '../../../../app/theme/app_colors.dart'; +import '../../../categories/domain/entities/category.dart'; +import '../../domain/entities/parse_rule.dart'; +import '../../domain/enums.dart'; + +/// Карточка правила в списке (§9.2 / §12.3). +class RuleCard extends StatelessWidget { + const RuleCard({ + super.key, + required this.rule, + required this.categoryById, + required this.onTap, + required this.onToggle, + }); + + final ParseRule rule; + final Map categoryById; + final VoidCallback onTap; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + + final icon = switch (rule.kind) { + ParseRuleKind.merchantToCategory => Icons.storefront_outlined, + ParseRuleKind.senderToAccount => Icons.credit_card_outlined, + ParseRuleKind.ignore => Icons.block_outlined, + }; + + final categoryName = + rule.categoryId != null ? categoryById[rule.categoryId]?.name : null; + final target = [ + rule.merchantCanonical, + categoryName, + ].whereType().join(' · '); + + return Dismissible( + key: ValueKey(rule.id), + direction: DismissDirection.endToStart, + confirmDismiss: (_) async { + onToggle(!rule.enabled); + return false; + }, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + color: p.cardSoft, + child: Icon( + rule.enabled ? Icons.toggle_off_outlined : Icons.toggle_on_outlined, + color: p.ink2), + ), + child: Opacity( + opacity: rule.enabled ? 1 : 0.5, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 22, color: p.ink2), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rule.pattern, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: p.ink), + ), + if (target.isNotEmpty) ...[ + const SizedBox(height: 2), + Text('→ $target', + style: TextStyle(fontSize: 13, color: p.ink2)), + ], + const SizedBox(height: 4), + Text( + rule.enabled + ? l10n.ruleMatchesCount(rule.matchCount) + : '${l10n.ruleMatchesCount(rule.matchCount)} · ${l10n.ruleDisabled}', + style: TextStyle(fontSize: 12, color: p.ink2), + ), + ], + ), + ), + Text(rule.matchMode.name, + style: TextStyle(fontSize: 11, color: p.line2)), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/features/profile/presentation/screens/profile_screen.dart b/lib/src/features/profile/presentation/screens/profile_screen.dart index ae2b2f1..5ef21c8 100644 --- a/lib/src/features/profile/presentation/screens/profile_screen.dart +++ b/lib/src/features/profile/presentation/screens/profile_screen.dart @@ -61,6 +61,12 @@ class ProfileScreen extends ConsumerWidget { title: l10n.profileCategoriesTile, onTap: () => context.push(AppRoutes.categoriesList), ), + Container(height: 1, color: p.line), + _NavRow( + icon: Icons.notifications_active_outlined, + title: l10n.profileParsingTile, + onTap: () => context.push(AppRoutes.parsingSettings), + ), ], ), ), diff --git a/test/features/notification_parsing/application/inbox_controller_test.dart b/test/features/notification_parsing/application/inbox_controller_test.dart new file mode 100644 index 0000000..ceff4f1 --- /dev/null +++ b/test/features/notification_parsing/application/inbox_controller_test.dart @@ -0,0 +1,329 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +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/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/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'; +import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart'; +import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart'; + +const _userId = 'u1'; +final _now = DateTime(2026, 5, 29, 14, 5); + +RawMessage _message() => RawMessage( + id: 'msg1', + userId: _userId, + packageName: 'ru.sberbankmobile', + body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA', + receivedAt: _now, + dedupHash: 'h', + status: RawMessageStatus.inbox, + createdAt: _now, + ); + +ParseDraft _draft() => ParseDraft( + rawMessageId: 'msg1', + type: TransactionType.expense, + amount: 124000, + cardLast4: '3456', + merchantRaw: 'PYATEROCHKA', + kind: TxKind.purchase, + source: ParseSource.regex, + ); + +// ── Fakes ──────────────────────────────────────────────────────────────── + +class _FakeTransactionRepo implements TransactionRepository { + final List> created = []; + + @override + Future create({ + required String userId, + required String accountId, + String? categoryId, + required TransactionType type, + required int amount, + required DateTime date, + String? merchant, + String? extraInfo, + String? transferToAccountId, + String? rawMessageId, + bool autoApplied = false, + String? appliedByRuleId, + }) async { + created.add({ + 'accountId': accountId, + 'categoryId': categoryId, + 'amount': amount, + 'merchant': merchant, + 'rawMessageId': rawMessageId, + }); + return Transaction( + id: 'tx1', + userId: userId, + accountId: accountId, + categoryId: categoryId, + type: type, + amount: amount, + date: date, + merchant: merchant, + rawMessageId: rawMessageId, + autoApplied: autoApplied, + appliedByRuleId: appliedByRuleId, + createdAt: _now, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +class _FakeRawMessagesRepo implements RawMessagesRepository { + final List<(String, String)> linked = []; + final List<(String, RawMessageStatus)> statusUpdates = []; + + @override + Future linkTransaction(String id, String transactionId) async { + linked.add((id, transactionId)); + } + + @override + Future updateStatus(String id, RawMessageStatus status) async { + statusUpdates.add((id, status)); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +class _FakeParseRulesRepo implements ParseRulesRepository { + final List> created = []; + + @override + Future create({ + required String userId, + required ParseRuleKind kind, + required MatchMode matchMode, + required String pattern, + int priority = 0, + String? merchantCanonical, + String? categoryId, + String? accountId, + }) async { + created.add({ + 'kind': kind, + 'matchMode': matchMode, + 'pattern': pattern, + 'merchantCanonical': merchantCanonical, + 'categoryId': categoryId, + }); + return ParseRule( + id: 'rule1', + userId: userId, + kind: kind, + matchMode: matchMode, + pattern: pattern, + merchantCanonical: merchantCanonical, + categoryId: categoryId, + accountId: accountId, + createdAt: _now, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +class _FakeRuleCandidatesRepo implements RuleCandidatesRepository { + final List<(String, String)> deleted = []; + final List> observed = []; + + @override + Future deleteByRawValue(String userId, String rawValue) async { + deleted.add((userId, rawValue)); + } + + @override + Future observe({ + required String userId, + required ParseRuleKind kind, + required String rawValue, + required String resolvedValue, + }) async { + observed.add({ + 'rawValue': rawValue, + 'resolvedValue': resolvedValue, + }); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +class _FakeAccountBindingsRepo implements AccountBindingsRepository { + AccountBinding? existing; + final List> created = []; + + @override + Future findByPackageAndCard( + String userId, + String packageName, + String cardLast4, + ) async => + existing; + + @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, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +void main() { + late _FakeTransactionRepo txRepo; + late _FakeRawMessagesRepo rawRepo; + late _FakeParseRulesRepo rulesRepo; + late _FakeRuleCandidatesRepo candidatesRepo; + late _FakeAccountBindingsRepo bindingsRepo; + late ProviderContainer container; + + setUp(() { + txRepo = _FakeTransactionRepo(); + rawRepo = _FakeRawMessagesRepo(); + rulesRepo = _FakeParseRulesRepo(); + candidatesRepo = _FakeRuleCandidatesRepo(); + bindingsRepo = _FakeAccountBindingsRepo(); + container = ProviderContainer( + overrides: [ + transactionRepositoryProvider.overrideWithValue(txRepo), + rawMessagesRepositoryProvider.overrideWithValue(rawRepo), + parseRulesRepositoryProvider.overrideWithValue(rulesRepo), + ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo), + accountBindingsRepositoryProvider.overrideWithValue(bindingsRepo), + ], + ); + }); + + tearDown(() => container.dispose()); + + InboxController controller() => + container.read(inboxControllerProvider.notifier); + + group('createRule', () { + test('creates transaction + rule, removes candidate, links, binds', () async { + await controller().createRule( + userId: _userId, + message: _message(), + draft: _draft(), + accountId: 'acc1', + categoryId: 'cat1', + merchantCanonical: 'PYATEROCHKA', + pattern: 'PYATEROCHKA', + ); + + expect(txRepo.created, hasLength(1)); + expect(txRepo.created.single['categoryId'], 'cat1'); + expect(txRepo.created.single['rawMessageId'], 'msg1'); + + expect(rulesRepo.created, hasLength(1)); + expect(rulesRepo.created.single['kind'], ParseRuleKind.merchantToCategory); + expect(rulesRepo.created.single['categoryId'], 'cat1'); + + 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'); + + 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( + userId: _userId, + message: _message(), + draft: _draft(), + accountId: 'acc1', + categoryId: 'cat1', + ); + + expect(txRepo.created, hasLength(1)); + expect(rulesRepo.created, isEmpty); + expect(rawRepo.linked, contains(('msg1', 'tx1'))); + expect(candidatesRepo.observed, hasLength(1)); + expect(candidatesRepo.observed.single['resolvedValue'], 'cat1'); + }); + }); + + group('ignore', () { + test('sets message status to ignored, no transaction', () async { + await controller().ignore(_message()); + + expect(rawRepo.statusUpdates, contains(('msg1', RawMessageStatus.ignored))); + expect(txRepo.created, isEmpty); + expect(rulesRepo.created, isEmpty); + }); + }); +} diff --git a/test/features/notification_parsing/parser/confidence_scorer_test.dart b/test/features/notification_parsing/parser/confidence_scorer_test.dart new file mode 100644 index 0000000..d264992 --- /dev/null +++ b/test/features/notification_parsing/parser/confidence_scorer_test.dart @@ -0,0 +1,130 @@ +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/data/parser/confidence_scorer.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/enums.dart'; + +RawMessage _msg(String body) => RawMessage( + id: 'm1', + userId: 'u1', + packageName: 'pkg', + body: body, + receivedAt: DateTime(2026, 5, 29), + dedupHash: 'h', + status: RawMessageStatus.pending, + createdAt: DateTime(2026, 5, 29), + ); + +ParseDraft _draft({ + String? merchantRaw = 'PYATEROCHKA', + int amount = 124000, + String currency = 'RUB', +}) => + ParseDraft( + rawMessageId: 'm1', + type: TransactionType.expense, + amount: amount, + currency: currency, + merchantRaw: merchantRaw, + source: ParseSource.regex, + ); + +ParseRule _rule({String? categoryId = 'c1'}) => ParseRule( + id: 'r1', + userId: 'u1', + kind: ParseRuleKind.merchantToCategory, + matchMode: MatchMode.contains, + pattern: 'PYATEROCHKA', + categoryId: categoryId, + createdAt: DateTime(2026, 1, 1), + ); + +void main() { + group('scoreDraft', () { + test('regex amount & type are 100', () { + final s = scoreDraft( + draft: _draft(), + message: _msg('Покупка PYATEROCHKA 1240 ₽ карта *3456'), + accountScore: 100, + ); + expect(s.amount, 100); + expect(s.type, 100); + expect(s.account, 100); + }); + + test('active rule → merchant & category 100', () { + final s = scoreDraft( + draft: _draft(), + message: _msg('Покупка PYATEROCHKA 1240 ₽ карта *3456'), + accountScore: 100, + merchantRule: _rule(), + ); + expect(s.merchant, 100); + expect(s.category, 100); + expect(s.otherFieldsMin, 100); + }); + + test('short merchant name is capped at 30', () { + final s = scoreDraft( + draft: _draft(merchantRaw: 'AB'), + message: _msg('Покупка AB 1240 ₽ карта *3456 что-то ещё длинное'), + accountScore: 100, + ); + expect(s.merchant, lessThanOrEqualTo(30)); + }); + + test('all-digit merchant capped at 20', () { + final s = scoreDraft( + draft: _draft(merchantRaw: '12345678'), + message: _msg('Покупка 12345678 1240 ₽ карта *3456 длинное тело тут'), + accountScore: 100, + ); + expect(s.merchant, lessThanOrEqualTo(20)); + }); + + test('category never exceeds merchant', () { + final s = scoreDraft( + draft: _draft(merchantRaw: 'AB'), + message: _msg('Покупка AB 1240 ₽ карта *3456 длинное тело сообщения'), + accountScore: 100, + ); + expect(s.category, lessThanOrEqualTo(s.merchant)); + }); + + test('short body caps amount/merchant/category at 50', () { + final s = scoreDraft( + draft: _draft(), + message: _msg('1240'), + accountScore: 100, + merchantRule: _rule(), + ); + expect(s.amount, lessThanOrEqualTo(50)); + expect(s.merchant, lessThanOrEqualTo(50)); + expect(s.category, lessThanOrEqualTo(50)); + }); + + test('empty currency caps amount at 40', () { + final s = scoreDraft( + draft: _draft(currency: ''), + message: _msg('Покупка PYATEROCHKA 1240 ₽ карта *3456 длинное тело'), + accountScore: 100, + ); + expect(s.amount, lessThanOrEqualTo(40)); + }); + }); + + group('looksLikeNonTransaction', () { + test('balance-only message → true', () { + expect(looksLikeNonTransaction('Доступно по карте 5000 ₽'), isTrue); + }); + + test('purchase message → false', () { + expect( + looksLikeNonTransaction('Покупка PYATEROCHKA Баланс 5000'), + isFalse, + ); + }); + }); +} diff --git a/test/features/notification_parsing/parser/decision_gate_test.dart b/test/features/notification_parsing/parser/decision_gate_test.dart new file mode 100644 index 0000000..8438927 --- /dev/null +++ b/test/features/notification_parsing/parser/decision_gate_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/confidence_scorer.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/decision_gate.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'; + +ParseRule _rule() => ParseRule( + id: 'r1', + userId: 'u1', + kind: ParseRuleKind.merchantToCategory, + matchMode: MatchMode.contains, + pattern: 'PYATEROCHKA', + categoryId: 'c1', + createdAt: DateTime(2026, 1, 1), + ); + +const _strongScores = FieldScores( + amount: 100, + account: 100, + type: 100, + merchant: 100, + category: 100, +); + +void main() { + group('decide', () { + test('rule + sanity + scores ≥ strictness → autoApply', () { + final d = decide( + merchantRule: _rule(), + sanityPassed: true, + scores: _strongScores, + strictness: 85, + amountMinor: 124000, + hasAccount: true, + ); + expect(d, GateDecision.autoApply); + }); + + test('no rule → inbox', () { + final d = decide( + merchantRule: null, + sanityPassed: true, + scores: _strongScores, + strictness: 85, + amountMinor: 124000, + hasAccount: true, + ); + expect(d, GateDecision.inbox); + }); + + test('scores below strictness → inbox', () { + final d = decide( + merchantRule: _rule(), + sanityPassed: true, + scores: const FieldScores( + amount: 100, + account: 45, + type: 100, + merchant: 100, + category: 100, + ), + strictness: 85, + amountMinor: 124000, + hasAccount: true, + ); + expect(d, GateDecision.inbox); + }); + + test('no account → inbox even with rule', () { + final d = decide( + merchantRule: _rule(), + sanityPassed: true, + scores: _strongScores, + strictness: 85, + amountMinor: 124000, + hasAccount: false, + ); + expect(d, GateDecision.inbox); + }); + + test('huge amount → inbox even with rule', () { + final d = decide( + merchantRule: _rule(), + sanityPassed: true, + scores: _strongScores, + strictness: 85, + amountMinor: 100001 * 100, + hasAccount: true, + ); + expect(d, GateDecision.inbox); + }); + + test('sanity failed → inbox', () { + final d = decide( + merchantRule: _rule(), + sanityPassed: false, + scores: _strongScores, + strictness: 85, + amountMinor: 124000, + hasAccount: true, + ); + expect(d, GateDecision.inbox); + }); + }); +} diff --git a/test/features/notification_parsing/parser/draft_codec_test.dart b/test/features/notification_parsing/parser/draft_codec_test.dart new file mode 100644 index 0000000..2ef52d7 --- /dev/null +++ b/test/features/notification_parsing/parser/draft_codec_test.dart @@ -0,0 +1,67 @@ +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/data/parser/draft_codec.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; + +void main() { + group('draft codec round-trip', () { + test('full draft + suggestion survives encode/decode', () { + final draft = ParseDraft( + rawMessageId: 'm1', + type: TransactionType.expense, + amount: 124000, + currency: 'RUB', + cardLast4: '3456', + merchantRaw: 'PYATEROCHKA 14', + counterpartyPhone: '+79991234567', + dateTime: DateTime(2026, 5, 29, 14, 5), + kind: TxKind.purchase, + accountId: 'a1', + merchantCanonical: 'PYATEROCHKA', + categoryId: 'c1', + source: ParseSource.regex, + ); + const sugg = RuleSuggestion( + merchantRaw: 'PYATEROCHKA 14', + merchantCanonical: 'PYATEROCHKA', + categoryId: 'c1', + categoryName: 'Продукты', + accountId: 'a1', + ); + + final decoded = decodeDraftBundle(encodeDraftBundle(draft, sugg)); + expect(decoded, isNotNull); + expect(decoded!.draft.amount, 124000); + expect(decoded.draft.type, TransactionType.expense); + expect(decoded.draft.kind, TxKind.purchase); + expect(decoded.draft.cardLast4, '3456'); + expect(decoded.draft.merchantRaw, 'PYATEROCHKA 14'); + expect(decoded.draft.dateTime, DateTime(2026, 5, 29, 14, 5)); + expect(decoded.draft.source, ParseSource.regex); + expect(decoded.suggestion, isNotNull); + expect(decoded.suggestion!.merchantCanonical, 'PYATEROCHKA'); + expect(decoded.suggestion!.categoryName, 'Продукты'); + }); + + test('draft without suggestion', () { + final draft = ParseDraft( + rawMessageId: 'm1', + type: TransactionType.income, + amount: 500000, + source: ParseSource.regex, + ); + final decoded = decodeDraftBundle(encodeDraftBundle(draft, null)); + expect(decoded, isNotNull); + expect(decoded!.suggestion, isNull); + expect(decoded.draft.type, TransactionType.income); + expect(decoded.draft.currency, 'RUB'); + }); + + test('null / empty json → null', () { + expect(decodeDraftBundle(null), isNull); + expect(decodeDraftBundle(''), isNull); + }); + }); +} diff --git a/test/features/notification_parsing/parser/misc_parser_test.dart b/test/features/notification_parsing/parser/misc_parser_test.dart new file mode 100644 index 0000000..90bb1e3 --- /dev/null +++ b/test/features/notification_parsing/parser/misc_parser_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/dedup.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/merchant_normalizer.dart'; + +void main() { + group('normalizeMerchant', () { + test('strips tail after star', () { + expect(normalizeMerchant('WBSPB*MOSCOW'), 'WBSPB'); + }); + + test('strips trailing point-of-sale digits', () { + expect(normalizeMerchant('PYATEROCHKA 2351'), 'PYATEROCHKA'); + }); + + test('collapses internal whitespace and trims', () { + expect(normalizeMerchant(' MAGNIT U '), 'MAGNIT U'); + }); + + test('falls back to trimmed raw if normalization empties it', () { + expect(normalizeMerchant(' 123 '), '123'); + }); + }); + + group('computeDedupHash', () { + test('is deterministic', () { + final a = computeDedupHash('pkg', 'Покупка 1240 ₽'); + final b = computeDedupHash('pkg', 'Покупка 1240 ₽'); + expect(a, b); + }); + + test('differs for different body', () { + final a = computeDedupHash('pkg', 'Покупка 1240 ₽'); + final b = computeDedupHash('pkg', 'Покупка 1250 ₽'); + expect(a, isNot(b)); + }); + + test('differs for different package', () { + final a = computeDedupHash('pkg1', 'same'); + final b = computeDedupHash('pkg2', 'same'); + expect(a, isNot(b)); + }); + + test('fixed-width 16-hex output', () { + final h = computeDedupHash('pkg', 'x'); + expect(h.length, 16); + expect(RegExp(r'^[0-9a-f]{16}$').hasMatch(h), isTrue); + }); + }); +} diff --git a/test/features/notification_parsing/parser/regex_parser_test.dart b/test/features/notification_parsing/parser/regex_parser_test.dart new file mode 100644 index 0000000..ffe7527 --- /dev/null +++ b/test/features/notification_parsing/parser/regex_parser_test.dart @@ -0,0 +1,100 @@ +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/data/parser/regex_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'; + +RawMessage _msg(String body, {String pkg = 'ru.sberbankmobile'}) { + final now = DateTime(2026, 5, 29, 14, 5); + return RawMessage( + id: 'm1', + userId: 'u1', + packageName: pkg, + body: body, + receivedAt: now, + dedupHash: 'h', + status: RawMessageStatus.pending, + createdAt: now, + ); +} + +void main() { + const parser = RegexParser(); + + group('parseAmountMinor', () { + test('plain integer → minor units', () { + expect(parseAmountMinor('1240'), 124000); + }); + + test('RU thousands separator + decimal comma', () { + expect(parseAmountMinor('1 240,50'), 124050); + }); + + test('dot decimal', () { + expect(parseAmountMinor('99.90'), 9990); + }); + + test('trailing dot in 3-digit group is NOT a decimal', () { + // «1.240» — точка как разделитель тысяч (3 цифры после) → 124000. + expect(parseAmountMinor('1.240'), 124000); + }); + + test('empty / garbage → null', () { + expect(parseAmountMinor(''), isNull); + expect(parseAmountMinor(' '), isNull); + }); + }); + + group('RegexParser.parse', () { + test('purchase amount→card→merchant (Сбер-подобный)', () { + final d = parser.parse( + _msg('Покупка 1240р Карта *3456 PYATEROCHKA 14:05 Баланс: 5000р'), + ); + expect(d, isNotNull); + expect(d!.type, TransactionType.expense); + expect(d.amount, 124000); + expect(d.cardLast4, '3456'); + expect(d.merchantRaw, 'PYATEROCHKA'); + expect(d.source, ParseSource.regex); + expect(d.kind, TxKind.purchase); + }); + + test('purchase card→amount→merchant (Тинькофф-подобный)', () { + final d = parser.parse( + _msg('Покупка. Карта *3456. 1 240 ₽. Пятёрочка. Доступно 5 000 ₽'), + ); + expect(d, isNotNull); + expect(d!.type, TransactionType.expense); + expect(d.amount, 124000); + expect(d.cardLast4, '3456'); + expect(d.merchantRaw, 'Пятёрочка'); + }); + + test('income (зачисление)', () { + final d = parser.parse(_msg('Зачисление 5000 ₽ Карта *3456 Зарплата')); + expect(d, isNotNull); + expect(d!.type, TransactionType.income); + expect(d.amount, 500000); + expect(d.cardLast4, '3456'); + }); + + test('SBP transfer by phone', () { + final d = parser.parse(_msg('Перевод 1000 ₽ на +79991234567 успешно')); + expect(d, isNotNull); + expect(d!.amount, 100000); + expect(d.counterpartyPhone, '+79991234567'); + }); + + test('refund → income', () { + final d = parser.parse(_msg('Возврат 1240 ₽ Карта *3456')); + expect(d, isNotNull); + expect(d!.type, TransactionType.income); + expect(d.amount, 124000); + }); + + test('non-matching body → null', () { + expect(parser.parse(_msg('Ваш баланс по карте *3456 составляет 5000 ₽')), + isNull); + }); + }); +} diff --git a/test/features/notification_parsing/parser/rule_lookup_test.dart b/test/features/notification_parsing/parser/rule_lookup_test.dart new file mode 100644 index 0000000..3c78399 --- /dev/null +++ b/test/features/notification_parsing/parser/rule_lookup_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/rule_lookup.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'; + +ParseRule _rule({ + required String id, + required String pattern, + MatchMode mode = MatchMode.contains, + ParseRuleKind kind = ParseRuleKind.merchantToCategory, + bool enabled = true, + int matchCount = 0, + int priority = 0, + String? categoryId = 'c1', +}) { + return ParseRule( + id: id, + userId: 'u1', + kind: kind, + matchMode: mode, + pattern: pattern, + enabled: enabled, + matchCount: matchCount, + priority: priority, + categoryId: categoryId, + createdAt: DateTime(2026, 1, 1), + ); +} + +void main() { + group('patternMatches', () { + test('contains is case-insensitive over body', () { + expect( + patternMatches('pyaterochka', MatchMode.contains, + body: 'Покупка PYATEROCHKA 1240'), + isTrue, + ); + }); + + test('exact matches normalized merchant only', () { + expect( + patternMatches('pyaterochka', MatchMode.exact, + body: 'long body', merchantRaw: 'PYATEROCHKA'), + isTrue, + ); + expect( + patternMatches('pyat', MatchMode.exact, + body: 'long body', merchantRaw: 'PYATEROCHKA'), + isFalse, + ); + }); + + test('regex match, invalid regex → false', () { + expect(patternMatches(r'\d{4}', MatchMode.regex, body: 'Карта 3456'), + isTrue); + expect(patternMatches('[invalid(', MatchMode.regex, body: 'x'), isFalse); + }); + + test('empty pattern → false', () { + expect(patternMatches(' ', MatchMode.contains, body: 'anything'), + isFalse); + }); + }); + + group('findMerchantRule', () { + const body = 'Покупка PYATEROCHKA 1240'; + + test('returns matching enabled merchant rule', () { + final r = findMerchantRule( + [_rule(id: 'a', pattern: 'PYATEROCHKA')], + body: body, + ); + expect(r?.id, 'a'); + }); + + test('ignores disabled rules', () { + final r = findMerchantRule( + [_rule(id: 'a', pattern: 'PYATEROCHKA', enabled: false)], + body: body, + ); + expect(r, isNull); + }); + + test('ignores ignore-kind rules', () { + final r = findMerchantRule( + [_rule(id: 'a', pattern: 'PYATEROCHKA', kind: ParseRuleKind.ignore)], + body: body, + ); + expect(r, isNull); + }); + + test('conflict: longer pattern wins', () { + final r = findMerchantRule( + [ + _rule(id: 'short', pattern: 'PYAT'), + _rule(id: 'long', pattern: 'PYATEROCHKA'), + ], + body: body, + ); + expect(r?.id, 'long'); + }); + + test('conflict: equal length → higher matchCount wins', () { + final r = findMerchantRule( + [ + _rule(id: 'a', pattern: 'PYATEROCHKA', matchCount: 1), + _rule(id: 'b', pattern: 'PYATEROCHKX', matchCount: 9), + ], + body: 'PYATEROCHKA PYATEROCHKX', + ); + expect(r?.id, 'b'); + }); + }); + + group('findIgnoreRule', () { + test('finds enabled ignore rule', () { + final r = findIgnoreRule( + [_rule(id: 'i', pattern: 'спам', kind: ParseRuleKind.ignore)], + body: 'это спам реклама', + ); + expect(r?.id, 'i'); + }); + }); +} diff --git a/test/features/notification_parsing/presentation/inbox_card_test.dart b/test/features/notification_parsing/presentation/inbox_card_test.dart new file mode 100644 index 0000000..54c235a --- /dev/null +++ b/test/features/notification_parsing/presentation/inbox_card_test.dart @@ -0,0 +1,177 @@ +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/categories/domain/entities/category.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'; +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/presentation/widgets/inbox_card.dart'; + +final _now = DateTime(2026, 5, 29, 14, 5); + +class FakeInboxController extends InboxController { + int createRuleCalls = 0; + int confirmOnceCalls = 0; + int ignoreCalls = 0; + String? lastCreateRuleCategory; + + @override + AsyncValue build() => const AsyncData(null); + + @override + Future createRule({ + required String userId, + required RawMessage message, + required ParseDraft draft, + required String accountId, + String? categoryId, + required String merchantCanonical, + required String pattern, + MatchMode matchMode = MatchMode.contains, + bool bindAccount = true, + }) async { + createRuleCalls++; + lastCreateRuleCategory = categoryId; + } + + @override + Future confirmOnce({ + required String userId, + required RawMessage message, + required ParseDraft draft, + required String accountId, + String? categoryId, + bool bindAccount = true, + }) async { + confirmOnceCalls++; + } + + @override + Future ignore(RawMessage message) async { + ignoreCalls++; + } +} + +RawMessage _recognizedMessage() { + final draft = ParseDraft( + rawMessageId: 'msg1', + type: TransactionType.expense, + amount: 124000, + cardLast4: '3456', + merchantRaw: 'PYATEROCHKA', + merchantCanonical: 'PYATEROCHKA', + categoryId: 'cat1', + kind: TxKind.purchase, + source: ParseSource.regex, + ); + return RawMessage( + id: 'msg1', + userId: 'u1', + packageName: 'ru.sberbankmobile', + body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA', + receivedAt: _now, + dedupHash: 'h', + status: RawMessageStatus.inbox, + draftJson: encodeDraftBundle(draft, null), + createdAt: _now, + ); +} + +Widget _host(FakeInboxController fake, RawMessage message) { + return ProviderScope( + overrides: [ + inboxControllerProvider.overrideWith(() => fake), + ], + child: MaterialApp( + theme: AppTheme.light(), + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: InboxCard( + message: message, + userId: 'u1', + defaultAccountId: 'acc1', + categoryById: const { + 'cat1': Category( + id: 'cat1', + userId: 'u1', + name: 'Продукты', + type: CategoryType.expense, + archived: false, + ), + }, + ), + ), + ), + ); +} + +void main() { + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + late FakeInboxController fake; + setUp(() => fake = FakeInboxController()); + + testWidgets('renders merchant, category and amount', (tester) async { + await tester.pumpWidget(_host(fake, _recognizedMessage())); + await tester.pump(); + + expect(find.text('PYATEROCHKA'), findsOneWidget); + expect(find.textContaining('Продукты'), findsWidgets); + expect(find.textContaining('240'), findsWidgets); // money formatted + }); + + testWidgets('tap "create rule" calls controller with category', (tester) async { + await tester.pumpWidget(_host(fake, _recognizedMessage())); + await tester.pump(); + + // Главная часть кнопки = ярлык «Правило: … → …». Категория задана → + // createRule вызывается напрямую, без открытия редактора. + final l10n = await AppLocalizations.delegate.load(const Locale('ru')); + await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты'))); + await tester.pump(); + + expect(fake.createRuleCalls, 1); + expect(fake.lastCreateRuleCategory, 'cat1'); + }); + + testWidgets('tap confirm-once calls controller', (tester) async { + await tester.pumpWidget(_host(fake, _recognizedMessage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.check)); + await tester.pump(); + + expect(fake.confirmOnceCalls, 1); + expect(fake.createRuleCalls, 0); + }); + + testWidgets('tap ignore calls controller', (tester) async { + await tester.pumpWidget(_host(fake, _recognizedMessage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + + expect(fake.ignoreCalls, 1); + }); + + testWidgets('unrecognized message shows manual-add fallback', (tester) async { + final msg = _recognizedMessage().copyWith(draftJson: null); + await tester.pumpWidget(_host(fake, msg)); + await tester.pump(); + + final l10n = await AppLocalizations.delegate.load(const Locale('ru')); + expect(find.text(l10n.inboxUnrecognized), findsOneWidget); + expect(find.text(l10n.inboxAddManually), findsOneWidget); + }); +}