Stage 1 sms
This commit is contained in:
+1
-1
@@ -102,4 +102,4 @@ fastlane/screenshots/**/*.png
|
|||||||
fastlane/test_output
|
fastlane/test_output
|
||||||
|
|
||||||
# Design files (локальные макеты, не нужны в VCS)
|
# 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)",
|
"accountValidationNameTooLong": "Name is too long (max 50 chars)",
|
||||||
"accountSaveError": "Failed to save the account. Please try again.",
|
"accountSaveError": "Failed to save the account. Please try again.",
|
||||||
"accountArchiveError": "Failed to archive 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:
|
/// In ru, this message translates to:
|
||||||
/// **'Счёт сохранён, но не удалось сделать его умолчательным.'**
|
/// **'Счёт сохранён, но не удалось сделать его умолчательным.'**
|
||||||
String get accountSetDefaultError;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -455,4 +455,221 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get accountSetDefaultError =>
|
String get accountSetDefaultError =>
|
||||||
'Account saved, but couldn\'t mark it as default.';
|
'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
|
@override
|
||||||
String get accountSetDefaultError =>
|
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 символов)",
|
"accountValidationNameTooLong": "Слишком длинное название (макс. 50 символов)",
|
||||||
"accountSaveError": "Не удалось сохранить счёт. Попробуйте ещё раз.",
|
"accountSaveError": "Не удалось сохранить счёт. Попробуйте ещё раз.",
|
||||||
"accountArchiveError": "Не удалось архивировать счёт. Попробуйте ещё раз.",
|
"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/categories_list_screen.dart';
|
||||||
import '../../features/categories/presentation/screens/category_form_screen.dart';
|
import '../../features/categories/presentation/screens/category_form_screen.dart';
|
||||||
import '../../features/home/presentation/screens/home_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/profile/presentation/screens/profile_screen.dart';
|
||||||
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
|
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
|
||||||
import '../../features/user/application/active_user_controller.dart';
|
import '../../features/user/application/active_user_controller.dart';
|
||||||
@@ -96,6 +101,34 @@ GoRouter appRouter(Ref ref) {
|
|||||||
AccountFormScreen(accountId: state.pathParameters['id']),
|
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(
|
StatefulShellRoute.indexedStack(
|
||||||
builder: (context, state, navigationShell) => AppScaffold(
|
builder: (context, state, navigationShell) => AppScaffold(
|
||||||
navigationShell: navigationShell,
|
navigationShell: navigationShell,
|
||||||
|
|||||||
@@ -19,4 +19,13 @@ class AppRoutes {
|
|||||||
static const accountNew = '/accounts/new';
|
static const accountNew = '/accounts/new';
|
||||||
static const accountEditPattern = '/accounts/edit/:id';
|
static const accountEditPattern = '/accounts/edit/:id';
|
||||||
static String accountEdit(String id) => '/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 '../../../accounts/domain/entities/account.dart';
|
||||||
import '../../../categories/application/categories_controller.dart';
|
import '../../../categories/application/categories_controller.dart';
|
||||||
import '../../../categories/domain/entities/category.dart';
|
import '../../../categories/domain/entities/category.dart';
|
||||||
|
import '../../../notification_parsing/application/parsing_worker.dart';
|
||||||
import '../../../transactions/domain/entities/transaction.dart';
|
import '../../../transactions/domain/entities/transaction.dart';
|
||||||
import '../../../user/application/active_user_controller.dart';
|
import '../../../user/application/active_user_controller.dart';
|
||||||
import '../month_summary.dart';
|
import '../month_summary.dart';
|
||||||
@@ -58,6 +59,8 @@ class _HomeContent extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final locale = Localizations.localeOf(context).toString();
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
// Активируем ParsingWorker (keepAlive): слушает raw_messages.pending.
|
||||||
|
ref.watch(parsingWorkerProvider(userId));
|
||||||
final categories =
|
final categories =
|
||||||
ref.watch(categoriesStreamProvider(userId)).value ??
|
ref.watch(categoriesStreamProvider(userId)).value ??
|
||||||
const <Category>[];
|
const <Category>[];
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../../app/l10n/l10n.dart';
|
import '../../../../app/l10n/l10n.dart';
|
||||||
|
import '../../../../app/router/app_routes.dart';
|
||||||
import '../../../../app/theme/app_colors.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';
|
import '../state/selected_category_filter.dart';
|
||||||
|
|
||||||
class MonthHeader extends ConsumerWidget {
|
class MonthHeader extends ConsumerWidget {
|
||||||
@@ -82,18 +86,37 @@ class MonthHeader extends ConsumerWidget {
|
|||||||
icon: Icon(Icons.search, size: 20, color: p.ink2),
|
icon: Icon(Icons.search, size: 20, color: p.ink2),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
_InboxButton(color: p.ink2),
|
||||||
onPressed: () {},
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
||||||
icon: Icon(Icons.notifications_outlined, size: 20, 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 {
|
class _NavButton extends StatelessWidget {
|
||||||
const _NavButton({
|
const _NavButton({
|
||||||
required this.icon,
|
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)))
|
(select(rawMessagesTable)..where((t) => t.id.equals(id)))
|
||||||
.getSingleOrNull();
|
.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 для идемпотентной вставки.
|
/// Поиск по dedupHash для идемпотентной вставки.
|
||||||
Future<RawMessagesTableData?> findByDedupHash(
|
Future<RawMessagesTableData?> findByDedupHash(
|
||||||
String userId, String dedupHash) =>
|
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.
|
/// Enums for the notification-parsing feature.
|
||||||
/// Drift converters are defined in the data layer; these are pure-Dart.
|
/// Drift converters are defined in the data layer; these are pure-Dart.
|
||||||
|
library;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RawMessageStatus
|
// 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,
|
title: l10n.profileCategoriesTile,
|
||||||
onTap: () => context.push(AppRoutes.categoriesList),
|
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