Stage 1 sms
This commit is contained in:
+1
-1
@@ -102,4 +102,4 @@ fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Design files (локальные макеты, не нужны в VCS)
|
||||
design/
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
+77
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'Пример: покупка';
|
||||
}
|
||||
|
||||
+77
-1
@@ -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": "Пример: покупка"
|
||||
}
|
||||
|
||||
@@ -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<void>(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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 <Category>[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<List<RawMessage>> inboxMessages(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchInbox(userId);
|
||||
|
||||
/// Реактивный счётчик Inbox — для бэджа на Home.
|
||||
@riverpod
|
||||
Stream<int> inboxCount(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchInboxCount(userId);
|
||||
|
||||
/// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя.
|
||||
@Riverpod(keepAlive: true)
|
||||
class InboxController extends _$InboxController {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
|
||||
/// «Создать правило»: транзакция + parse_rule + удаление кандидата.
|
||||
/// Все последующие похожие сообщения auto-apply молча.
|
||||
Future<void> 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<void> 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<void> ignore(RawMessage message) async {
|
||||
await ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.updateStatus(message.id, RawMessageStatus.ignored);
|
||||
}
|
||||
|
||||
/// «Игнорировать» + правило-исключение (kind=ignore).
|
||||
Future<void> 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<void> 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<void> 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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<ParsingSettings> 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<void> setEnabled(bool value) async {
|
||||
await ref
|
||||
.read(appDatabaseProvider)
|
||||
.settingsDao
|
||||
.setPreference(_kEnabled, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(enabled: value));
|
||||
}
|
||||
|
||||
Future<void> setStrictness(int value) async {
|
||||
await ref
|
||||
.read(appDatabaseProvider)
|
||||
.settingsDao
|
||||
.setPreference(_kStrictness, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(strictness: value));
|
||||
}
|
||||
}
|
||||
@@ -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<List<RawMessage>> 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<String> _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<void> _drain(String userId, List<RawMessage> 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<void> _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<void> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<ParseRule>> parseRulesList(Ref ref, String userId) =>
|
||||
ref.watch(parseRulesRepositoryProvider).watchByUser(userId);
|
||||
|
||||
/// Одно правило по id — для редактора.
|
||||
@riverpod
|
||||
Future<ParseRule?> parseRuleById(Ref ref, String id) =>
|
||||
ref.watch(parseRulesRepositoryProvider).findById(id);
|
||||
|
||||
/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном.
|
||||
@riverpod
|
||||
Future<List<RawMessage>> 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<void> build() => const AsyncData(null);
|
||||
|
||||
Future<ParseRule> 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<void> 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<void> setEnabled(String id, {required bool enabled}) =>
|
||||
ref.read(parseRulesRepositoryProvider).setEnabled(id, enabled: enabled);
|
||||
|
||||
Future<void> delete(String id) =>
|
||||
ref.read(parseRulesRepositoryProvider).deleteById(id);
|
||||
}
|
||||
@@ -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<BankTemplate> 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,
|
||||
),
|
||||
];
|
||||
@@ -48,6 +48,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
(select(rawMessagesTable)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
|
||||
/// Последние сообщения пользователя начиная с [since] — для live-превью
|
||||
/// в редакторе правил (матч паттерна по реальным сообщениям).
|
||||
Future<List<RawMessagesTableData>> 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<RawMessagesTableData?> findByDedupHash(
|
||||
String userId, String dedupHash) =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<AccountResolution> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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(<String, dynamic>{
|
||||
'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<String, dynamic>;
|
||||
final draftMap = map['draft'] as Map<String, dynamic>?;
|
||||
if (draftMap == null) return null;
|
||||
final suggMap = map['suggestion'] as Map<String, dynamic>?;
|
||||
return DraftBundle(
|
||||
draft: _draftFromJson(draftMap),
|
||||
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _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<String, dynamic> 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<String, dynamic> _suggestionToJson(RuleSuggestion s) => {
|
||||
'merchantRaw': s.merchantRaw,
|
||||
'merchantCanonical': s.merchantCanonical,
|
||||
'categoryId': s.categoryId,
|
||||
'categoryName': s.categoryName,
|
||||
'accountId': s.accountId,
|
||||
};
|
||||
|
||||
RuleSuggestion _suggestionFromJson(Map<String, dynamic> 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?,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<BankTemplate> 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);
|
||||
@@ -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<ParseRule> 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<ParseRule> 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
+87
@@ -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<List<AccountBinding>> watchByUser(String userId) => _dao
|
||||
.watchByUser(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByPackageAndCard(
|
||||
String userId,
|
||||
String packageName,
|
||||
String cardLast4,
|
||||
) async =>
|
||||
(await _dao.findByPackageAndCard(userId, packageName, cardLast4))
|
||||
?.toDomain();
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByBankKeyAndCard(
|
||||
String userId,
|
||||
String bankKey,
|
||||
String cardLast4,
|
||||
) async =>
|
||||
(await _dao.findByBankKeyAndCard(userId, bankKey, cardLast4))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByPhone(String userId, String phone) async =>
|
||||
(await _dao.findByPhone(userId, phone))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<List<AccountBinding>> findByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
) async =>
|
||||
(await _dao.findByPackageName(userId, packageName))
|
||||
.map((r) => r.toDomain())
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<AccountBinding> 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<void> incrementMatchCount(String id) => _dao.incrementMatchCount(id);
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||
}
|
||||
+89
@@ -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<List<ParseRule>> watchByUser(String userId) => _dao
|
||||
.watchByUser(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Future<List<ParseRule>> getEnabledByUser(String userId) async =>
|
||||
(await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList();
|
||||
|
||||
@override
|
||||
Future<List<ParseRule>> getByUser(String userId) async =>
|
||||
(await _dao.getByUser(userId)).map((r) => r.toDomain()).toList();
|
||||
|
||||
@override
|
||||
Future<ParseRule?> findById(String id) async =>
|
||||
(await _dao.findById(id))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<ParseRule> 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<void> 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<void> setEnabled(String id, {required bool enabled}) =>
|
||||
_dao.setEnabled(id, enabled: enabled);
|
||||
|
||||
@override
|
||||
Future<void> incrementMatchCount(String id, DateTime now) =>
|
||||
_dao.incrementMatchCount(id, now);
|
||||
|
||||
@override
|
||||
Future<void> decrementWeight(String id) => _dao.decrementWeight(id);
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||
}
|
||||
+105
@@ -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<List<RawMessage>> watchPending(String userId) => _dao
|
||||
.watchPending(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<List<RawMessage>> watchInbox(String userId) => _dao
|
||||
.watchInbox(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId);
|
||||
|
||||
@override
|
||||
Future<RawMessage?> findById(String id) async =>
|
||||
(await _dao.findById(id))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<RawMessage?> findByDedupHash(String userId, String dedupHash) async =>
|
||||
(await _dao.findByDedupHash(userId, dedupHash))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<List<RawMessage>> recentByUser(String userId, DateTime since) async =>
|
||||
(await _dao.getRecentByUser(userId, since))
|
||||
.map((r) => r.toDomain())
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<RawMessage> 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<void> updateStatus(String id, RawMessageStatus status) =>
|
||||
_dao.updateStatus(id, status);
|
||||
|
||||
@override
|
||||
Future<void> 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<void> linkTransaction(String id, String transactionId) =>
|
||||
_dao.linkTransaction(id, transactionId);
|
||||
|
||||
@override
|
||||
Future<void> incrementParseAttempts(String id) =>
|
||||
_dao.incrementParseAttempts(id);
|
||||
}
|
||||
+60
@@ -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<List<RuleCandidate>> watchByUser(String userId) => _dao
|
||||
.watchByUser(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Future<RuleCandidate?> findByRawValue(
|
||||
String userId,
|
||||
ParseRuleKind kind,
|
||||
String rawValue,
|
||||
) async =>
|
||||
(await _dao.findByRawValue(userId, kind, rawValue))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<void> 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<void> deleteByRawValue(String userId, String rawValue) =>
|
||||
_dao.deleteByRawValue(userId, rawValue);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/// Enums for the notification-parsing feature.
|
||||
/// Drift converters are defined in the data layer; these are pure-Dart.
|
||||
library;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RawMessageStatus
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import '../entities/account_binding.dart';
|
||||
|
||||
/// Доступ к привязкам «карта/телефон → счёт» ([AccountBinding]).
|
||||
///
|
||||
/// Используется на шаге 3 pipeline (account_resolver) для разрешения accountId.
|
||||
abstract interface class AccountBindingsRepository {
|
||||
Stream<List<AccountBinding>> watchByUser(String userId);
|
||||
|
||||
Future<AccountBinding?> findByPackageAndCard(
|
||||
String userId,
|
||||
String packageName,
|
||||
String cardLast4,
|
||||
);
|
||||
|
||||
Future<AccountBinding?> findByBankKeyAndCard(
|
||||
String userId,
|
||||
String bankKey,
|
||||
String cardLast4,
|
||||
);
|
||||
|
||||
Future<AccountBinding?> findByPhone(String userId, String phone);
|
||||
|
||||
Future<List<AccountBinding>> findByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
);
|
||||
|
||||
Future<AccountBinding> create({
|
||||
required String userId,
|
||||
String? packageName,
|
||||
String? bankKey,
|
||||
String? cardLast4,
|
||||
String? phone,
|
||||
required String accountId,
|
||||
});
|
||||
|
||||
Future<void> incrementMatchCount(String id);
|
||||
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import '../entities/parse_rule.dart';
|
||||
import '../enums.dart';
|
||||
|
||||
/// Доступ к пользовательским правилам парсинга ([ParseRule]).
|
||||
abstract interface class ParseRulesRepository {
|
||||
/// Все правила пользователя — для экрана «Правила парсинга».
|
||||
Stream<List<ParseRule>> watchByUser(String userId);
|
||||
|
||||
/// Только активные правила — для pipeline (rule_lookup).
|
||||
Future<List<ParseRule>> getEnabledByUser(String userId);
|
||||
|
||||
Future<List<ParseRule>> getByUser(String userId);
|
||||
|
||||
Future<ParseRule?> findById(String id);
|
||||
|
||||
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
|
||||
Future<ParseRule> create({
|
||||
required String userId,
|
||||
required ParseRuleKind kind,
|
||||
required MatchMode matchMode,
|
||||
required String pattern,
|
||||
int priority,
|
||||
String? merchantCanonical,
|
||||
String? categoryId,
|
||||
String? accountId,
|
||||
});
|
||||
|
||||
Future<void> update(ParseRule rule);
|
||||
|
||||
Future<void> setEnabled(String id, {required bool enabled});
|
||||
|
||||
/// Инкремент matchCount + lastMatchAt при срабатывании.
|
||||
Future<void> incrementMatchCount(String id, DateTime now);
|
||||
|
||||
/// Откат («правило сработало неправильно»): weight-- и деактивация при <=0.
|
||||
Future<void> decrementWeight(String id);
|
||||
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
+54
@@ -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<List<RawMessage>> watchPending(String userId);
|
||||
|
||||
/// Сообщения, ожидающие подтверждения в Inbox.
|
||||
Stream<List<RawMessage>> watchInbox(String userId);
|
||||
|
||||
/// Реактивный счётчик Inbox — для бэджа на Home.
|
||||
Stream<int> watchInboxCount(String userId);
|
||||
|
||||
Future<RawMessage?> findById(String id);
|
||||
|
||||
Future<RawMessage?> findByDedupHash(String userId, String dedupHash);
|
||||
|
||||
/// Сообщения за период (для live-превью правил).
|
||||
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
|
||||
|
||||
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
|
||||
/// [dedupHash] уже есть — возвращает существующее, не создавая дубль.
|
||||
Future<RawMessage> insertIncoming({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
String? title,
|
||||
required String body,
|
||||
required DateTime receivedAt,
|
||||
});
|
||||
|
||||
Future<void> updateStatus(String id, RawMessageStatus status);
|
||||
|
||||
/// Запись результатов парсинга (draft + per-field confidence).
|
||||
Future<void> updateAfterParse({
|
||||
required String id,
|
||||
required RawMessageStatus status,
|
||||
String? draftJson,
|
||||
int? confidenceAmount,
|
||||
int? confidenceAccount,
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
});
|
||||
|
||||
/// Привязка к созданной транзакции (status → applied).
|
||||
Future<void> linkTransaction(String id, String transactionId);
|
||||
|
||||
Future<void> incrementParseAttempts(String id);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import '../entities/rule_candidate.dart';
|
||||
import '../enums.dart';
|
||||
|
||||
/// Доступ к накопленным наблюдениям ([RuleCandidate]) — источнику
|
||||
/// предложений правил для Inbox.
|
||||
abstract interface class RuleCandidatesRepository {
|
||||
Stream<List<RuleCandidate>> watchByUser(String userId);
|
||||
|
||||
Future<RuleCandidate?> findByRawValue(
|
||||
String userId,
|
||||
ParseRuleKind kind,
|
||||
String rawValue,
|
||||
);
|
||||
|
||||
/// Наблюдение: создаёт кандидата или инкрементит seenCount существующего,
|
||||
/// обновляя resolvedValue и lastSeenAt.
|
||||
Future<void> observe({
|
||||
required String userId,
|
||||
required ParseRuleKind kind,
|
||||
required String rawValue,
|
||||
required String resolvedValue,
|
||||
});
|
||||
|
||||
/// Удаляет всех кандидатов для rawValue (при создании правила).
|
||||
Future<void> deleteByRawValue(String userId, String rawValue);
|
||||
}
|
||||
@@ -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<DebugInjectScreen> createState() => _DebugInjectScreenState();
|
||||
}
|
||||
|
||||
class _DebugInjectScreenState extends ConsumerState<DebugInjectScreen> {
|
||||
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<void> _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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <RawMessage>[];
|
||||
final categories =
|
||||
ref.watch(categoriesStreamProvider(userId)).value ?? const <Category>[];
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+185
@@ -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 <ParseRule>[])
|
||||
.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<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return SegmentedButton<int>(
|
||||
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<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<RuleEditorScreen> createState() => _RuleEditorScreenState();
|
||||
}
|
||||
|
||||
class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
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<void> _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 <Category>[];
|
||||
final categoryName = _categoryId == null
|
||||
? null
|
||||
: categories.where((c) => c.id == _categoryId).firstOrNull?.name;
|
||||
final accounts =
|
||||
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
|
||||
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<int>(
|
||||
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<MatchMode> 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<RulesListScreen> createState() => _RulesListScreenState();
|
||||
}
|
||||
|
||||
class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
||||
_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 <ParseRule>[])
|
||||
.where(_matches)
|
||||
.toList();
|
||||
final categories =
|
||||
ref.watch(categoriesStreamProvider(userId)).value ?? const <Category>[];
|
||||
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<void> _createRule(BuildContext context, String userId) async {
|
||||
final result =
|
||||
await context.push<RuleEditorResult?>(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Category> 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<String, Category> 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<void> _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<void> _openEditor(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String? accountId,
|
||||
required String? categoryId,
|
||||
required String merchant,
|
||||
}) async {
|
||||
final draft = bundle.draft;
|
||||
final result = await context.push<RuleEditorResult?>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Category> categoryById;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<bool> 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<String>().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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<Map<String, Object?>> created = [];
|
||||
|
||||
@override
|
||||
Future<Transaction> 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<void> linkTransaction(String id, String transactionId) async {
|
||||
linked.add((id, transactionId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<Map<String, Object?>> created = [];
|
||||
|
||||
@override
|
||||
Future<ParseRule> 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<Map<String, Object?>> observed = [];
|
||||
|
||||
@override
|
||||
Future<void> deleteByRawValue(String userId, String rawValue) async {
|
||||
deleted.add((userId, rawValue));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<Map<String, Object?>> created = [];
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByPackageAndCard(
|
||||
String userId,
|
||||
String packageName,
|
||||
String cardLast4,
|
||||
) async =>
|
||||
existing;
|
||||
|
||||
@override
|
||||
Future<AccountBinding> create({
|
||||
required String userId,
|
||||
String? packageName,
|
||||
String? bankKey,
|
||||
String? cardLast4,
|
||||
String? phone,
|
||||
required String accountId,
|
||||
}) async {
|
||||
created.add({
|
||||
'packageName': packageName,
|
||||
'cardLast4': cardLast4,
|
||||
'accountId': accountId,
|
||||
});
|
||||
return AccountBinding(
|
||||
id: 'b1',
|
||||
userId: userId,
|
||||
packageName: packageName,
|
||||
cardLast4: cardLast4,
|
||||
accountId: accountId,
|
||||
createdAt: _now,
|
||||
);
|
||||
}
|
||||
|
||||
@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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> build() => const AsyncData(null);
|
||||
|
||||
@override
|
||||
Future<void> 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<void> confirmOnce({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required ParseDraft draft,
|
||||
required String accountId,
|
||||
String? categoryId,
|
||||
bool bindAccount = true,
|
||||
}) async {
|
||||
confirmOnceCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user