diff --git a/CLAUDE.md b/CLAUDE.md index 0a3f76e..58fdf8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ lib/ theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative) theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now core/ - database/app_database.dart # @DriftDatabase, schemaVersion=6 + database/app_database.dart # @DriftDatabase, schemaVersion=7 database/tables/ # users / app_preferences / settings / accounts / categories / transactions database/daos/ # *_dao.dart with .watch*() methods database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here) @@ -105,7 +105,8 @@ Every domain table has a `userId` FK → `users`. | `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was `note`), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt | **Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`, -`parse_rules`, `rule_candidates`, `account_bindings`, `transfer_pairing_blocklist`. Their enums live +`parse_rules`, `rule_candidates`, `account_bindings` (+`isDefault` per-app default binding), +`source_apps` (allowlist of monitored apps), `transfer_pairing_blocklist`. Their enums live in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both imported by `app_database.dart`). diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 29ce449..f6f9ad2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -379,5 +379,32 @@ "habitAnalysisTile": "Habit analysis", "profileSeedDemoTile": "Fill with demo transactions", - "profileSeedDemoSuccess": "Demo transactions added" + "profileSeedDemoSuccess": "Demo transactions added", + + "commonAdd": "Add", + "commonCancel": "Cancel", + "commonDelete": "Delete", + + "parsingAppsTile": "Source apps", + + "sourceAppsTitle": "Source apps", + "sourceAppsAddedSection": "Monitored", + "sourceAppsAvailableSection": "Available", + "sourceAppsEmpty": "No apps added yet. Pick a bank below or add one manually.", + "sourceAppsAddManual": "Add manually", + "sourceAppsPackageLabel": "Package name", + "sourceAppsNameLabel": "Display name (optional)", + + "appBindingsTitle": "Account bindings", + "appBindingsEmpty": "No bindings yet. Add one to map a card to an account.", + "appBindingsAnyCard": "Any card (default)", + "appBindingsSetDefault": "Set as default for this app", + "appBindingsAddTitle": "New binding", + "appBindingsCardLabel": "Card last 4 digits", + "appBindingsCardHelper": "Leave empty to match any card", + "appBindingsDefaultLabel": "Default for this app", + + "ruleKindMerchant": "Merchant → category", + "ruleKindAccount": "Sender → account", + "ruleEditorAccountPick": "Select account" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a8b4d33..3a9413b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1753,6 +1753,138 @@ abstract class AppLocalizations { /// In ru, this message translates to: /// **'Демо транзакции добавлены'** String get profileSeedDemoSuccess; + + /// No description provided for @commonAdd. + /// + /// In ru, this message translates to: + /// **'Добавить'** + String get commonAdd; + + /// No description provided for @commonCancel. + /// + /// In ru, this message translates to: + /// **'Отмена'** + String get commonCancel; + + /// No description provided for @commonDelete. + /// + /// In ru, this message translates to: + /// **'Удалить'** + String get commonDelete; + + /// No description provided for @parsingAppsTile. + /// + /// In ru, this message translates to: + /// **'Приложения-источники'** + String get parsingAppsTile; + + /// No description provided for @sourceAppsTitle. + /// + /// In ru, this message translates to: + /// **'Приложения-источники'** + String get sourceAppsTitle; + + /// No description provided for @sourceAppsAddedSection. + /// + /// In ru, this message translates to: + /// **'Отслеживаются'** + String get sourceAppsAddedSection; + + /// No description provided for @sourceAppsAvailableSection. + /// + /// In ru, this message translates to: + /// **'Доступные'** + String get sourceAppsAvailableSection; + + /// No description provided for @sourceAppsEmpty. + /// + /// In ru, this message translates to: + /// **'Пока нет приложений. Выберите банк ниже или добавьте вручную.'** + String get sourceAppsEmpty; + + /// No description provided for @sourceAppsAddManual. + /// + /// In ru, this message translates to: + /// **'Добавить вручную'** + String get sourceAppsAddManual; + + /// No description provided for @sourceAppsPackageLabel. + /// + /// In ru, this message translates to: + /// **'Имя пакета'** + String get sourceAppsPackageLabel; + + /// No description provided for @sourceAppsNameLabel. + /// + /// In ru, this message translates to: + /// **'Отображаемое имя (необязательно)'** + String get sourceAppsNameLabel; + + /// No description provided for @appBindingsTitle. + /// + /// In ru, this message translates to: + /// **'Привязки счетов'** + String get appBindingsTitle; + + /// No description provided for @appBindingsEmpty. + /// + /// In ru, this message translates to: + /// **'Пока нет привязок. Добавьте, чтобы связать карту со счётом.'** + String get appBindingsEmpty; + + /// No description provided for @appBindingsAnyCard. + /// + /// In ru, this message translates to: + /// **'Любая карта (по умолчанию)'** + String get appBindingsAnyCard; + + /// No description provided for @appBindingsSetDefault. + /// + /// In ru, this message translates to: + /// **'Сделать счётом по умолчанию для приложения'** + String get appBindingsSetDefault; + + /// No description provided for @appBindingsAddTitle. + /// + /// In ru, this message translates to: + /// **'Новая привязка'** + String get appBindingsAddTitle; + + /// No description provided for @appBindingsCardLabel. + /// + /// In ru, this message translates to: + /// **'Последние 4 цифры карты'** + String get appBindingsCardLabel; + + /// No description provided for @appBindingsCardHelper. + /// + /// In ru, this message translates to: + /// **'Оставьте пустым для любой карты'** + String get appBindingsCardHelper; + + /// No description provided for @appBindingsDefaultLabel. + /// + /// In ru, this message translates to: + /// **'По умолчанию для приложения'** + String get appBindingsDefaultLabel; + + /// No description provided for @ruleKindMerchant. + /// + /// In ru, this message translates to: + /// **'Мерчант → категория'** + String get ruleKindMerchant; + + /// No description provided for @ruleKindAccount. + /// + /// In ru, this message translates to: + /// **'Отправитель → счёт'** + String get ruleKindAccount; + + /// No description provided for @ruleEditorAccountPick. + /// + /// In ru, this message translates to: + /// **'Выбрать счёт'** + String get ruleEditorAccountPick; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 280a50a..c3a9190 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -917,4 +917,72 @@ class AppLocalizationsEn extends AppLocalizations { @override String get profileSeedDemoSuccess => 'Demo transactions added'; + + @override + String get commonAdd => 'Add'; + + @override + String get commonCancel => 'Cancel'; + + @override + String get commonDelete => 'Delete'; + + @override + String get parsingAppsTile => 'Source apps'; + + @override + String get sourceAppsTitle => 'Source apps'; + + @override + String get sourceAppsAddedSection => 'Monitored'; + + @override + String get sourceAppsAvailableSection => 'Available'; + + @override + String get sourceAppsEmpty => + 'No apps added yet. Pick a bank below or add one manually.'; + + @override + String get sourceAppsAddManual => 'Add manually'; + + @override + String get sourceAppsPackageLabel => 'Package name'; + + @override + String get sourceAppsNameLabel => 'Display name (optional)'; + + @override + String get appBindingsTitle => 'Account bindings'; + + @override + String get appBindingsEmpty => + 'No bindings yet. Add one to map a card to an account.'; + + @override + String get appBindingsAnyCard => 'Any card (default)'; + + @override + String get appBindingsSetDefault => 'Set as default for this app'; + + @override + String get appBindingsAddTitle => 'New binding'; + + @override + String get appBindingsCardLabel => 'Card last 4 digits'; + + @override + String get appBindingsCardHelper => 'Leave empty to match any card'; + + @override + String get appBindingsDefaultLabel => 'Default for this app'; + + @override + String get ruleKindMerchant => 'Merchant → category'; + + @override + String get ruleKindAccount => 'Sender → account'; + + @override + String get ruleEditorAccountPick => 'Select account'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7d3285e..3b2d31d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -930,4 +930,73 @@ class AppLocalizationsRu extends AppLocalizations { @override String get profileSeedDemoSuccess => 'Демо транзакции добавлены'; + + @override + String get commonAdd => 'Добавить'; + + @override + String get commonCancel => 'Отмена'; + + @override + String get commonDelete => 'Удалить'; + + @override + String get parsingAppsTile => 'Приложения-источники'; + + @override + String get sourceAppsTitle => 'Приложения-источники'; + + @override + String get sourceAppsAddedSection => 'Отслеживаются'; + + @override + String get sourceAppsAvailableSection => 'Доступные'; + + @override + String get sourceAppsEmpty => + 'Пока нет приложений. Выберите банк ниже или добавьте вручную.'; + + @override + String get sourceAppsAddManual => 'Добавить вручную'; + + @override + String get sourceAppsPackageLabel => 'Имя пакета'; + + @override + String get sourceAppsNameLabel => 'Отображаемое имя (необязательно)'; + + @override + String get appBindingsTitle => 'Привязки счетов'; + + @override + String get appBindingsEmpty => + 'Пока нет привязок. Добавьте, чтобы связать карту со счётом.'; + + @override + String get appBindingsAnyCard => 'Любая карта (по умолчанию)'; + + @override + String get appBindingsSetDefault => + 'Сделать счётом по умолчанию для приложения'; + + @override + String get appBindingsAddTitle => 'Новая привязка'; + + @override + String get appBindingsCardLabel => 'Последние 4 цифры карты'; + + @override + String get appBindingsCardHelper => 'Оставьте пустым для любой карты'; + + @override + String get appBindingsDefaultLabel => 'По умолчанию для приложения'; + + @override + String get ruleKindMerchant => 'Мерчант → категория'; + + @override + String get ruleKindAccount => 'Отправитель → счёт'; + + @override + String get ruleEditorAccountPick => 'Выбрать счёт'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1997a8d..9409701 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -379,5 +379,32 @@ "habitAnalysisTile": "Анализ привычек", "profileSeedDemoTile": "Заполнить демо транзакциями", - "profileSeedDemoSuccess": "Демо транзакции добавлены" + "profileSeedDemoSuccess": "Демо транзакции добавлены", + + "commonAdd": "Добавить", + "commonCancel": "Отмена", + "commonDelete": "Удалить", + + "parsingAppsTile": "Приложения-источники", + + "sourceAppsTitle": "Приложения-источники", + "sourceAppsAddedSection": "Отслеживаются", + "sourceAppsAvailableSection": "Доступные", + "sourceAppsEmpty": "Пока нет приложений. Выберите банк ниже или добавьте вручную.", + "sourceAppsAddManual": "Добавить вручную", + "sourceAppsPackageLabel": "Имя пакета", + "sourceAppsNameLabel": "Отображаемое имя (необязательно)", + + "appBindingsTitle": "Привязки счетов", + "appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.", + "appBindingsAnyCard": "Любая карта (по умолчанию)", + "appBindingsSetDefault": "Сделать счётом по умолчанию для приложения", + "appBindingsAddTitle": "Новая привязка", + "appBindingsCardLabel": "Последние 4 цифры карты", + "appBindingsCardHelper": "Оставьте пустым для любой карты", + "appBindingsDefaultLabel": "По умолчанию для приложения", + + "ruleKindMerchant": "Мерчант → категория", + "ruleKindAccount": "Отправитель → счёт", + "ruleEditorAccountPick": "Выбрать счёт" } diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index 15534e1..0065d80 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -11,12 +11,14 @@ import '../../features/categories/presentation/screens/categories_list_screen.da import '../../features/categories/presentation/screens/category_form_screen.dart'; import '../../features/home/presentation/screens/home_screen.dart'; import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart'; +import '../../features/notification_parsing/presentation/screens/app_bindings_screen.dart'; import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart'; import '../../features/notification_parsing/presentation/screens/inbox_screen.dart'; import '../../features/notification_parsing/presentation/screens/parsing_log_screen.dart'; import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart'; import '../../features/notification_parsing/presentation/screens/rule_editor_screen.dart'; import '../../features/notification_parsing/presentation/screens/rules_list_screen.dart'; +import '../../features/notification_parsing/presentation/screens/source_apps_screen.dart'; import '../../features/profile/presentation/screens/profile_screen.dart'; import '../../features/transactions/presentation/screens/transaction_form_screen.dart'; import '../../features/user/application/active_user_controller.dart'; @@ -140,6 +142,16 @@ GoRouter appRouter(Ref ref) { path: AppRoutes.parsingLog, builder: (context, state) => const ParsingLogScreen(), ), + GoRoute( + path: AppRoutes.parsingApps, + builder: (context, state) => const SourceAppsScreen(), + ), + GoRoute( + path: AppRoutes.parsingAppBindingsPattern, + builder: (context, state) => AppBindingsScreen( + packageName: Uri.decodeComponent(state.pathParameters['pkg']!), + ), + ), GoRoute( path: AppRoutes.habitAnalysis, pageBuilder: (context, state) => diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart index 3d79772..5774305 100644 --- a/lib/src/app/router/app_routes.dart +++ b/lib/src/app/router/app_routes.dart @@ -31,4 +31,8 @@ class AppRoutes { static String parsingRuleEdit(String id) => '/settings/parsing/rules/$id'; static const parsingDebugInject = '/settings/parsing/debug'; static const parsingLog = '/settings/parsing/log'; + static const parsingApps = '/settings/parsing/apps'; + static const parsingAppBindingsPattern = '/settings/parsing/apps/:pkg'; + static String parsingAppBindings(String packageName) => + '/settings/parsing/apps/${Uri.encodeComponent(packageName)}'; } diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index 026a478..12a8776 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -21,11 +21,13 @@ import '../../features/notification_parsing/data/drift/tables/raw_messages_table import '../../features/notification_parsing/data/drift/tables/parse_rules_table.dart'; import '../../features/notification_parsing/data/drift/tables/rule_candidates_table.dart'; import '../../features/notification_parsing/data/drift/tables/account_bindings_table.dart'; +import '../../features/notification_parsing/data/drift/tables/source_apps_table.dart'; import '../../features/notification_parsing/data/drift/tables/transfer_pairing_blocklist_table.dart'; import '../../features/notification_parsing/data/drift/daos/raw_messages_dao.dart'; import '../../features/notification_parsing/data/drift/daos/parse_rules_dao.dart'; import '../../features/notification_parsing/data/drift/daos/rule_candidates_dao.dart'; import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart'; +import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart'; part 'app_database.g.dart'; @@ -42,6 +44,7 @@ part 'app_database.g.dart'; ParseRulesTable, RuleCandidatesTable, AccountBindingsTable, + SourceAppsTable, TransferPairingBlocklistTable, ], daos: [ @@ -55,6 +58,7 @@ part 'app_database.g.dart'; ParseRulesDao, RuleCandidatesDao, AccountBindingsDao, + SourceAppsDao, ], ) class AppDatabase extends _$AppDatabase { @@ -64,7 +68,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -116,6 +120,12 @@ class AppDatabase extends _$AppDatabase { await m.addColumn( settingsTable, settingsTable.habitTrackingEnabled); } + if (from < 7) { + // v6 → v7: умолчательные привязки + allowlist приложений-источников. + await m.addColumn( + accountBindingsTable, accountBindingsTable.isDefault); + await m.createTable(sourceAppsTable); + } }, ); diff --git a/lib/src/features/home/presentation/widgets/month_kpi_card.dart b/lib/src/features/home/presentation/widgets/month_kpi_card.dart index 96cb0a9..1c0fcb7 100644 --- a/lib/src/features/home/presentation/widgets/month_kpi_card.dart +++ b/lib/src/features/home/presentation/widgets/month_kpi_card.dart @@ -45,10 +45,11 @@ class MonthKpiCard extends ConsumerWidget { ), MoneyText( s.balanceMinor, - color: p.ink, + color: s.balanceMinor < 0 ? p.negative : p.ink, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.4, + withSign: true, ), ], ), diff --git a/lib/src/features/notification_parsing/application/account_bindings_controller.dart b/lib/src/features/notification_parsing/application/account_bindings_controller.dart new file mode 100644 index 0000000..a522793 --- /dev/null +++ b/lib/src/features/notification_parsing/application/account_bindings_controller.dart @@ -0,0 +1,55 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../domain/entities/account_binding.dart'; +import 'notification_parsing_providers.dart'; + +part 'account_bindings_controller.g.dart'; + +/// Все привязки пользователя — для экрана привязок приложения. +@riverpod +Stream> accountBindingsList(Ref ref, String userId) => + ref.watch(accountBindingsRepositoryProvider).watchByUser(userId); + +/// CRUD над привязками «карта/телефон → счёт». +@Riverpod(keepAlive: true) +class AccountBindingsController extends _$AccountBindingsController { + @override + AsyncValue build() => const AsyncData(null); + + Future add({ + required String userId, + required String packageName, + required String accountId, + String? cardLast4, + String? phone, + bool isDefault = false, + }) async { + state = const AsyncLoading(); + try { + final repo = ref.read(accountBindingsRepositoryProvider); + final binding = await repo.create( + userId: userId, + packageName: packageName, + accountId: accountId, + cardLast4: cardLast4, + phone: phone, + ); + // Единственный default на пакет: проводим через setDefault (атомарно + // снимает флаг у прочих привязок), а не через create(isDefault: true). + if (isDefault) { + await repo.setDefault(binding.id, userId, packageName); + } + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future setDefault(String id, String userId, String packageName) => ref + .read(accountBindingsRepositoryProvider) + .setDefault(id, userId, packageName); + + Future delete(String id) => + ref.read(accountBindingsRepositoryProvider).deleteById(id); +} diff --git a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart index 45a795e..386e90b 100644 --- a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart +++ b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart @@ -5,10 +5,12 @@ import '../data/repositories/account_bindings_repository_impl.dart'; import '../data/repositories/parse_rules_repository_impl.dart'; import '../data/repositories/raw_messages_repository_impl.dart'; import '../data/repositories/rule_candidates_repository_impl.dart'; +import '../data/repositories/source_apps_repository_impl.dart'; import '../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'; +import '../domain/repositories/source_apps_repository.dart'; part 'notification_parsing_providers.g.dart'; @@ -31,3 +33,12 @@ RuleCandidatesRepository ruleCandidatesRepository(Ref ref) => AccountBindingsRepository accountBindingsRepository(Ref ref) => AccountBindingsRepositoryImpl( ref.watch(appDatabaseProvider).accountBindingsDao); + +@Riverpod(keepAlive: true) +SourceAppsRepository sourceAppsRepository(Ref ref) => + SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao); + +/// Множество включённых packageName пользователя — вход allowlist-фильтра. +@riverpod +Future> enabledSourcePackages(Ref ref, String userId) => + ref.watch(sourceAppsRepositoryProvider).enabledPackages(userId); diff --git a/lib/src/features/notification_parsing/application/parsing_worker.dart b/lib/src/features/notification_parsing/application/parsing_worker.dart index 7a4133d..fcdd4d4 100644 --- a/lib/src/features/notification_parsing/application/parsing_worker.dart +++ b/lib/src/features/notification_parsing/application/parsing_worker.dart @@ -1,5 +1,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../accounts/application/account_providers.dart'; import '../../categories/application/category_providers.dart'; import '../../categories/domain/entities/category.dart'; import '../../transactions/application/transactions_controller.dart'; @@ -105,6 +106,17 @@ class ParsingWorker extends _$ParsingWorker { final settings = await ref.read(parsingSettingsControllerProvider.future); if (!settings.enabled) return; // фича выключена — оставляем pending. + // Allowlist (§A): парсим только включённые приложения-источники. Делаем + // ДО AI, чтобы не тратить токены на посторонние пакеты. + final enabled = + await ref.read(enabledSourcePackagesProvider(userId).future); + if (!enabled.contains(msg.packageName)) { + await ref + .read(rawMessagesRepositoryProvider) + .updateStatus(msg.id, RawMessageStatus.ignored); + return; + } + // Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored. await _extractViaAi(userId, msg, settings); } @@ -225,25 +237,35 @@ class ParsingWorker extends _$ParsingWorker { }) async { final repo = ref.read(rawMessagesRepositoryProvider); - // 3. Разрешение счёта. + // 4. Правила грузим раньше — нужны резолверу (senderToAccount). + final rules = + await ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId); + if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) != + null) { + await repo.updateStatus(msg.id, RawMessageStatus.ignored); + return; + } + + // 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты. + final globalDefaultAccountId = (await ref + .read(accountRepositoryProvider) + .watchDefault(userId) + .first) + ?.id; final resolver = AccountResolver(ref.read(accountBindingsRepositoryProvider)); final resolution = await resolver.resolve( userId: userId, packageName: msg.packageName, + body: msg.body, cardLast4: draft0.cardLast4, phone: draft0.counterpartyPhone, + merchantRaw: draft0.merchantRaw, + senderRules: rules, + globalDefaultAccountId: globalDefaultAccountId, ); var draft = draft0.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); @@ -314,7 +336,8 @@ class ParsingWorker extends _$ParsingWorker { scores: scores, strictness: settings.strictness, amountMinor: draft.amount, - hasAccount: draft.accountId != null, + accountResolved: draft.accountId != null, + accountTrusted: resolution.trusted, ); if (decision == GateDecision.autoApply) { diff --git a/lib/src/features/notification_parsing/application/source_apps_controller.dart b/lib/src/features/notification_parsing/application/source_apps_controller.dart new file mode 100644 index 0000000..f46eb28 --- /dev/null +++ b/lib/src/features/notification_parsing/application/source_apps_controller.dart @@ -0,0 +1,43 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../domain/entities/source_app.dart'; +import 'notification_parsing_providers.dart'; + +part 'source_apps_controller.g.dart'; + +/// Все приложения-источники пользователя — для экрана управления. +@riverpod +Stream> sourceAppsList(Ref ref, String userId) => + ref.watch(sourceAppsRepositoryProvider).watchByUser(userId); + +/// CRUD над приложениями-источниками (allowlist). +@Riverpod(keepAlive: true) +class SourceAppsController extends _$SourceAppsController { + @override + AsyncValue build() => const AsyncData(null); + + Future add({ + required String userId, + required String packageName, + String? displayName, + }) async { + state = const AsyncLoading(); + try { + await ref.read(sourceAppsRepositoryProvider).add( + userId: userId, + packageName: packageName, + displayName: displayName, + ); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future setEnabled(String id, {required bool enabled}) => + ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled); + + Future delete(String id) => + ref.read(sourceAppsRepositoryProvider).deleteById(id); +} diff --git a/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart index 231dd6a..08b8ad3 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/account_bindings_dao.dart @@ -69,6 +69,19 @@ class AccountBindingsDao extends DatabaseAccessor t.userId.equals(userId) & t.packageName.equals(packageName))) .get(); + /// Умолчательная привязка приложения (card неизвестна) — §B #4. + Future findDefaultByPackageName( + String userId, + String packageName, + ) => + (select(accountBindingsTable) + ..where((t) => + t.userId.equals(userId) & + t.packageName.equals(packageName) & + t.isDefault.equals(true)) + ..limit(1)) + .getSingleOrNull(); + // ── Mutations ────────────────────────────────────────────────────────────── Future insert(AccountBindingsTableCompanion companion) => @@ -86,6 +99,22 @@ class AccountBindingsDao extends DatabaseAccessor updates: {accountBindingsTable}, ); + /// Делает привязку [id] умолчательной для её пакета: снимает флаг у всех + /// прочих привязок этого packageName и выставляет у выбранной (атомарно). + Future setDefault( + String id, + String userId, + String packageName, + ) => + transaction(() async { + await (update(accountBindingsTable) + ..where((t) => + t.userId.equals(userId) & t.packageName.equals(packageName))) + .write(const AccountBindingsTableCompanion(isDefault: Value(false))); + await (update(accountBindingsTable)..where((t) => t.id.equals(id))) + .write(const AccountBindingsTableCompanion(isDefault: Value(true))); + }); + Future deleteById(String id) => (delete(accountBindingsTable)..where((t) => t.id.equals(id))).go(); } diff --git a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart new file mode 100644 index 0000000..96d53c6 --- /dev/null +++ b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart @@ -0,0 +1,49 @@ +import 'package:drift/drift.dart'; +import '../../../../../core/database/app_database.dart'; +import '../tables/source_apps_table.dart'; + +part 'source_apps_dao.g.dart'; + +@DriftAccessor(tables: [SourceAppsTable]) +class SourceAppsDao extends DatabaseAccessor + with _$SourceAppsDaoMixin { + SourceAppsDao(super.db); + + /// Все приложения пользователя — для экрана управления. + Stream> watchByUser(String userId) => + (select(sourceAppsTable) + ..where((t) => t.userId.equals(userId)) + ..orderBy([(t) => OrderingTerm(expression: t.displayName)])) + .watch(); + + /// Включённые packageName — для allowlist-фильтра. + Future> enabledPackages(String userId) async { + final rows = await (select(sourceAppsTable) + ..where((t) => t.userId.equals(userId) & t.enabled.equals(true))) + .get(); + return rows.map((r) => r.packageName).toList(); + } + + Future findByPackageName( + String userId, + String packageName, + ) => + (select(sourceAppsTable) + ..where((t) => + t.userId.equals(userId) & t.packageName.equals(packageName)) + ..limit(1)) + .getSingleOrNull(); + + Future findById(String id) => + (select(sourceAppsTable)..where((t) => t.id.equals(id))).getSingleOrNull(); + + Future insert(SourceAppsTableCompanion companion) => + into(sourceAppsTable).insert(companion); + + Future setEnabled(String id, {required bool enabled}) => + (update(sourceAppsTable)..where((t) => t.id.equals(id))) + .write(SourceAppsTableCompanion(enabled: Value(enabled))); + + Future deleteById(String id) => + (delete(sourceAppsTable)..where((t) => t.id.equals(id))).go(); +} diff --git a/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart b/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart index c998feb..c0b6150 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/account_bindings_table.dart @@ -25,6 +25,10 @@ class AccountBindingsTable extends Table { TextColumn get accountId => text().references(AccountsTable, #id, onDelete: KeyAction.cascade)(); + /// Привязка по умолчанию для этого приложения (когда карта не распознана). + /// Единственная на пакет — обеспечивается через [AccountBindingsDao.setDefault]. + BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); + IntColumn get matchCount => integer().withDefault(const Constant(0))(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); diff --git a/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart b/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart new file mode 100644 index 0000000..b73da30 --- /dev/null +++ b/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import '../../../../../core/database/tables/users_table.dart'; + +/// Приложение-источник уведомлений, добавленное пользователем (allowlist). +/// +/// Присутствие строки = приложение добавлено в мониторинг; [enabled] — следим +/// ли за ним сейчас. Парсятся только включённые приложения (см. +/// `parsing_worker` allowlist-фильтр). Заводится из каталога +/// (`source_apps_catalog.dart`) или вручную (кастомный packageName). +/// +/// Уникальный индекс по (userId, packageName). +class SourceAppsTable extends Table { + @override + String get tableName => 'source_apps'; + + TextColumn get id => text()(); + TextColumn get userId => + text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); + + TextColumn get packageName => text()(); + TextColumn get displayName => text().nullable()(); + + BoolColumn get enabled => boolean().withDefault(const Constant(true))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {id}; + + @override + List> get uniqueKeys => [ + {userId, packageName}, + ]; +} diff --git a/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart b/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart index 1956a42..c189075 100644 --- a/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/account_binding_mapper.dart @@ -11,6 +11,7 @@ extension AccountBindingMapper on AccountBindingsTableData { cardLast4: cardLast4, phone: phone, accountId: accountId, + isDefault: isDefault, matchCount: matchCount, createdAt: createdAt, ); diff --git a/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart b/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart new file mode 100644 index 0000000..f6a4a23 --- /dev/null +++ b/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart @@ -0,0 +1,14 @@ +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/source_app.dart'; + +/// Маппер: строка Drift → доменная сущность [SourceApp]. +extension SourceAppMapper on SourceAppsTableData { + SourceApp toDomain() => SourceApp( + id: id, + userId: userId, + packageName: packageName, + displayName: displayName, + enabled: enabled, + createdAt: createdAt, + ); +} diff --git a/lib/src/features/notification_parsing/data/parser/account_resolver.dart b/lib/src/features/notification_parsing/data/parser/account_resolver.dart index 1320f07..57c1770 100644 --- a/lib/src/features/notification_parsing/data/parser/account_resolver.dart +++ b/lib/src/features/notification_parsing/data/parser/account_resolver.dart @@ -1,24 +1,48 @@ +import '../../domain/entities/parse_rule.dart'; import '../../domain/repositories/account_bindings_repository.dart'; +import 'rule_lookup.dart'; -/// Результат разрешения счёта из уведомления (шаг 3 pipeline). +/// Откуда взят разрешённый счёт (для диагностики / подсветки). +enum AccountSource { + bindingCard, + bindingPhone, + senderRule, + singleBinding, + appDefault, + ambiguous, + globalDefault, + none, +} + +/// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B). class AccountResolution { const AccountResolution({ required this.accountId, required this.score, + required this.trusted, + required this.source, this.bindingId, }); - /// Разрешённый счёт (null, если binding не нашёлся). + /// Разрешённый счёт (null, если ничего не нашлось). final String? accountId; - /// Confidence по счёту 0..100 (§8.2). + /// Confidence по счёту 0..100 (§8.2) — для подсветки «?», НЕ для gate. final int score; + /// Можно ли доверять счёту для авто-применения. Именно это поле (а не score) + /// решает судьбу в gate: неоднозначность (#5) → false → Inbox; осознанные + /// дефолты (#4, #6) → true → авто-применение разрешено. + final bool trusted; + + final AccountSource source; + /// id сработавшей привязки — для инкремента matchCount. final String? bindingId; } -/// Разрешает `accountId` по `account_bindings` (карта/телефон → счёт). +/// Разрешает `accountId` по цепочке источников (§B): привязки карта/телефон, +/// `senderToAccount`-правила, per-app default и глобальный дефолт счёта. class AccountResolver { const AccountResolver(this._bindings); @@ -27,38 +51,105 @@ class AccountResolver { Future resolve({ required String userId, required String packageName, + required String body, String? cardLast4, String? phone, + String? merchantRaw, + List senderRules = const [], + String? globalDefaultAccountId, }) async { + // #1 — binding по packageName + cardLast4. if (cardLast4 != null) { final b = await _bindings.findByPackageAndCard(userId, packageName, cardLast4); if (b != null) { return AccountResolution( - accountId: b.accountId, score: 100, bindingId: b.id); + accountId: b.accountId, + score: 100, + trusted: true, + source: AccountSource.bindingCard, + bindingId: b.id, + ); } } + // #1 — binding по телефону (СБП). if (phone != null) { final b = await _bindings.findByPhone(userId, phone); if (b != null) { return AccountResolution( - accountId: b.accountId, score: 100, bindingId: b.id); + accountId: b.accountId, + score: 100, + trusted: true, + source: AccountSource.bindingPhone, + bindingId: b.id, + ); } } - final byPkg = await _bindings.findByPackageName(userId, packageName); - if (byPkg.length == 1) { - // Один счёт для этого банка — высокая уверенность (§8.2). + // #2 — senderToAccount-правило (матч по телу). + final senderRule = + findSenderRule(senderRules, body: body, merchantRaw: merchantRaw); + if (senderRule?.accountId != null) { 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); + accountId: senderRule!.accountId, + score: 90, + trusted: true, + source: AccountSource.senderRule, + ); } - return const AccountResolution(accountId: null, score: 15); + final byPkg = await _bindings.findByPackageName(userId, packageName); + + // #3 — единственный binding по packageName. + if (byPkg.length == 1) { + return AccountResolution( + accountId: byPkg.first.accountId, + score: 75, + trusted: true, + source: AccountSource.singleBinding, + bindingId: byPkg.first.id, + ); + } + + if (byPkg.length > 1) { + // #4 — per-app default binding (card неизвестна). + final def = await _bindings.findDefaultByPackageName(userId, packageName); + if (def != null) { + return AccountResolution( + accountId: def.accountId, + score: 70, + trusted: true, + source: AccountSource.appDefault, + bindingId: def.id, + ); + } + // #5 — несколько bindings без default-флага → «первый», но не доверяем. + return AccountResolution( + accountId: byPkg.first.accountId, + score: 45, + trusted: false, + source: AccountSource.ambiguous, + bindingId: byPkg.first.id, + ); + } + + // #6 — глобальный Account.isDefault. + if (globalDefaultAccountId != null) { + return AccountResolution( + accountId: globalDefaultAccountId, + score: 40, + trusted: true, + source: AccountSource.globalDefault, + ); + } + + // #7 — ничего. + return const AccountResolution( + accountId: null, + score: 15, + trusted: false, + source: AccountSource.none, + ); } } diff --git a/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart b/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart index 49b0c2f..44ce356 100644 --- a/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart +++ b/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart @@ -21,8 +21,11 @@ class FieldScores { final int category; /// Минимум по «прочим полям» — это и есть вход в gate (§8.7). - int get otherFieldsMin => - [amount, account, type].reduce((a, b) => a < b ? a : b); + /// + /// Счёт исключён из gate-минимума: судьбу счёта решает `accountTrusted` + /// в [decide] (см. §B), а не числовой score. Account-score остаётся для + /// подсветки «?» в Inbox. + int get otherFieldsMin => [amount, type].reduce((a, b) => a < b ? a : b); } /// Считает 5 per-field оценок (§8.1–8.6). diff --git a/lib/src/features/notification_parsing/data/parser/decision_gate.dart b/lib/src/features/notification_parsing/data/parser/decision_gate.dart index 76470f5..30264e3 100644 --- a/lib/src/features/notification_parsing/data/parser/decision_gate.dart +++ b/lib/src/features/notification_parsing/data/parser/decision_gate.dart @@ -11,20 +11,25 @@ const int _hugeAmountMinor = 100000 * 100; /// Decision gate (§8.7). /// /// Молча в ленту только если есть подтверждённое правило для мерчанта, -/// sanity пройден, и min(сумма, счёт, тип) ≥ порога строгости. -/// Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту. +/// sanity пройден, счёт разрешён и доверенный, и min(сумма, тип) ≥ порога +/// строгости. Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту. +/// +/// [accountResolved] — счёт вообще нашёлся; [accountTrusted] — ему можно +/// доверять для авто-применения (§B): неоднозначные multi-binding (#5) дают +/// false и уходят в Inbox, осознанные дефолты (#4/#6) — true. GateDecision decide({ required ParseRule? merchantRule, required bool sanityPassed, required FieldScores scores, required int strictness, required int amountMinor, - required bool hasAccount, + required bool accountResolved, + required bool accountTrusted, }) { if (amountMinor > _hugeAmountMinor) return GateDecision.inbox; - // Без разрешённого счёта нечем создать транзакцию молча. - if (!hasAccount) return GateDecision.inbox; + // Без разрешённого/доверенного счёта нечем создать транзакцию молча. + if (!accountResolved || !accountTrusted) return GateDecision.inbox; if (merchantRule != null && sanityPassed && diff --git a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart index 7735685..ad21a7f 100644 --- a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart +++ b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart @@ -56,6 +56,25 @@ ParseRule? findMerchantRule( return matches.isEmpty ? null : matches.first; } +/// Ищет активное правило `senderToAccount`, матчащее тело сообщения (§B #2). +/// +/// При конфликте — та же специфичность, что у [findMerchantRule]. +ParseRule? findSenderRule( + List rules, { + required String body, + String? merchantRaw, +}) { + final matches = rules + .where((r) => + r.enabled && + r.kind == ParseRuleKind.senderToAccount && + r.accountId != null && + ruleMatches(r, body: body, merchantRaw: merchantRaw)) + .toList() + ..sort(_bySpecificity); + return matches.isEmpty ? null : matches.first; +} + /// Ищет активное правило-исключение (kind=ignore) для сообщения. ParseRule? findIgnoreRule( List rules, { diff --git a/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart index 63a2261..fcdba1b 100644 --- a/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/account_bindings_repository_impl.dart @@ -46,6 +46,13 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository { .map((r) => r.toDomain()) .toList(); + @override + Future findDefaultByPackageName( + String userId, + String packageName, + ) async => + (await _dao.findDefaultByPackageName(userId, packageName))?.toDomain(); + @override Future create({ required String userId, @@ -56,6 +63,8 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository { required String accountId, }) async { final id = const Uuid().v4(); + // Привязка всегда создаётся не-дефолтной; default назначается отдельно + // через [setDefault] (атомарно снимает флаг у прочих привязок пакета). await _dao.insert( AccountBindingsTableCompanion.insert( id: id, @@ -82,6 +91,10 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository { @override Future incrementMatchCount(String id) => _dao.incrementMatchCount(id); + @override + Future setDefault(String id, String userId, String packageName) => + _dao.setDefault(id, userId, packageName); + @override Future deleteById(String id) => _dao.deleteById(id); } diff --git a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart new file mode 100644 index 0000000..be69ce4 --- /dev/null +++ b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart @@ -0,0 +1,51 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/source_app.dart'; +import '../../domain/repositories/source_apps_repository.dart'; +import '../drift/daos/source_apps_dao.dart'; +import '../mappers/source_app_mapper.dart'; + +class SourceAppsRepositoryImpl implements SourceAppsRepository { + const SourceAppsRepositoryImpl(this._dao); + final SourceAppsDao _dao; + + @override + Stream> watchByUser(String userId) => _dao + .watchByUser(userId) + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future> enabledPackages(String userId) async => + (await _dao.enabledPackages(userId)).toSet(); + + @override + Future add({ + required String userId, + required String packageName, + String? displayName, + }) async { + final existing = await _dao.findByPackageName(userId, packageName); + if (existing != null) return existing.toDomain(); + + final id = const Uuid().v4(); + await _dao.insert( + SourceAppsTableCompanion.insert( + id: id, + userId: userId, + packageName: packageName, + displayName: Value(displayName), + ), + ); + final row = await _dao.findById(id); + return row!.toDomain(); + } + + @override + Future setEnabled(String id, {required bool enabled}) => + _dao.setEnabled(id, enabled: enabled); + + @override + Future deleteById(String id) => _dao.deleteById(id); +} diff --git a/lib/src/features/notification_parsing/data/source_apps/source_apps_catalog.dart b/lib/src/features/notification_parsing/data/source_apps/source_apps_catalog.dart new file mode 100644 index 0000000..7a2579f --- /dev/null +++ b/lib/src/features/notification_parsing/data/source_apps/source_apps_catalog.dart @@ -0,0 +1,87 @@ +/// Шаблон известного приложения-источника уведомлений (хардкод-каталог). +/// +/// Источник вариантов для экрана «Приложения-источники»: пользователь +/// выбирает банк из списка, и в `source_apps` заводится строка. Позже этот +/// каталог заменится нативным списком установленных приложений. +class SourceAppTemplate { + const SourceAppTemplate({ + required this.packageName, + required this.displayName, + this.bankKey, + }); + + final String packageName; + final String displayName; + + /// Нормализованный ключ банка (для будущей привязки к шаблонам разбора). + final String? bankKey; +} + +/// Захардкоженный каталог известных банков РФ. +const List kSourceAppsCatalog = [ + SourceAppTemplate( + packageName: 'ru.sberbankmobile', + displayName: 'СберБанк', + bankKey: 'sber', + ), + SourceAppTemplate( + packageName: 'com.idamob.tinkoff.android', + displayName: 'Т-Банк', + bankKey: 'tinkoff', + ), + SourceAppTemplate( + packageName: 'ru.alfabank.mobile.android', + displayName: 'Альфа-Банк', + bankKey: 'alfa', + ), + SourceAppTemplate( + packageName: 'ru.vtb24.mobilebanking.android', + displayName: 'ВТБ', + bankKey: 'vtb', + ), + SourceAppTemplate( + packageName: 'ru.gazprombank.android.mobilebank.app', + displayName: 'Газпромбанк', + bankKey: 'gazprom', + ), + SourceAppTemplate( + packageName: 'ru.raiffeisennews', + displayName: 'Райффайзен Банк', + bankKey: 'raiffeisen', + ), + SourceAppTemplate( + packageName: 'com.openbank', + displayName: 'Банк Открытие', + bankKey: 'open', + ), + SourceAppTemplate( + packageName: 'ru.rosbank.android', + displayName: 'Росбанк', + bankKey: 'rosbank', + ), + SourceAppTemplate( + packageName: 'ru.pochta.bank', + displayName: 'Почта Банк', + bankKey: 'pochta', + ), + SourceAppTemplate( + packageName: 'ru.simpls.brs.android', + displayName: 'Банк Русский Стандарт', + bankKey: 'rsb', + ), + SourceAppTemplate( + packageName: 'ru.mkb.mobile', + displayName: 'МКБ', + bankKey: 'mkb', + ), + SourceAppTemplate( + packageName: 'ru.akbars.mobile', + displayName: 'Ак Барс Банк', + bankKey: 'akbars', + ), + SourceAppTemplate( + packageName: 'com.android.mms', + displayName: 'SMS', + bankKey: null, + ), +]; diff --git a/lib/src/features/notification_parsing/domain/entities/account_binding.dart b/lib/src/features/notification_parsing/domain/entities/account_binding.dart index 2c97c3a..bf251c9 100644 --- a/lib/src/features/notification_parsing/domain/entities/account_binding.dart +++ b/lib/src/features/notification_parsing/domain/entities/account_binding.dart @@ -22,6 +22,9 @@ abstract class AccountBinding with _$AccountBinding { String? phone, required String accountId, + + /// Привязка по умолчанию для приложения (карта не распознана). + @Default(false) bool isDefault, @Default(0) int matchCount, required DateTime createdAt, }) = _AccountBinding; diff --git a/lib/src/features/notification_parsing/domain/entities/source_app.dart b/lib/src/features/notification_parsing/domain/entities/source_app.dart new file mode 100644 index 0000000..7a12b80 --- /dev/null +++ b/lib/src/features/notification_parsing/domain/entities/source_app.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'source_app.freezed.dart'; + +/// Приложение-источник уведомлений, добавленное пользователем (allowlist). +/// +/// Парсятся только включённые ([enabled]) приложения. Заводится из каталога +/// (`source_apps_catalog.dart`) или вручную (кастомный [packageName]). +@freezed +abstract class SourceApp with _$SourceApp { + const factory SourceApp({ + required String id, + required String userId, + required String packageName, + String? displayName, + @Default(true) bool enabled, + required DateTime createdAt, + }) = _SourceApp; +} diff --git a/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart b/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart index 91b3131..3591021 100644 --- a/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart @@ -25,6 +25,12 @@ abstract interface class AccountBindingsRepository { String packageName, ); + /// Умолчательная привязка приложения (карта не распознана) — §B #4. + Future findDefaultByPackageName( + String userId, + String packageName, + ); + Future create({ required String userId, String? packageName, @@ -36,5 +42,8 @@ abstract interface class AccountBindingsRepository { Future incrementMatchCount(String id); + /// Делает привязку умолчательной для её пакета (сбрасывает флаг у прочих). + Future setDefault(String id, String userId, String packageName); + Future deleteById(String id); } diff --git a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart new file mode 100644 index 0000000..1a72810 --- /dev/null +++ b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart @@ -0,0 +1,22 @@ +import '../entities/source_app.dart'; + +/// Доступ к приложениям-источникам уведомлений ([SourceApp]) — allowlist. +abstract interface class SourceAppsRepository { + /// Все приложения-источники пользователя — для экрана управления. + Stream> watchByUser(String userId); + + /// Множество включённых packageName — для allowlist-фильтра в пайплайне. + Future> enabledPackages(String userId); + + /// Добавляет приложение (включено по умолчанию). Возвращает сущность. + /// Если строка с таким packageName уже есть — возвращает существующую. + Future add({ + required String userId, + required String packageName, + String? displayName, + }); + + Future setEnabled(String id, {required bool enabled}); + + Future deleteById(String id); +} diff --git a/lib/src/features/notification_parsing/presentation/screens/app_bindings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/app_bindings_screen.dart new file mode 100644 index 0000000..0febfb8 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/app_bindings_screen.dart @@ -0,0 +1,160 @@ +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 '../../../accounts/application/accounts_controller.dart'; +import '../../../accounts/domain/entities/account.dart'; +import '../../../transactions/presentation/widgets/account_picker_sheet.dart'; +import '../../../user/application/active_user_controller.dart'; +import '../../application/account_bindings_controller.dart'; +import '../../domain/entities/account_binding.dart'; + +/// Привязки выбранного приложения-источника: card/phone → счёт + дефолт (§A). +class AppBindingsScreen extends ConsumerWidget { + const AppBindingsScreen({super.key, required this.packageName}); + + final String packageName; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final l10n = context.l10n; + final userId = ref.watch(activeUserControllerProvider).value?.id; + + if (userId == null) { + return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink()); + } + + final bindings = (ref.watch(accountBindingsListProvider(userId)).value ?? + const []) + .where((b) => b.packageName == packageName) + .toList(); + final accounts = ref.watch(accountsStreamProvider(userId)).value ?? + const []; + final accountById = {for (final a in accounts) a.id: a}; + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(l10n.appBindingsTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _addBinding(context, ref, userId), + ), + ], + ), + body: bindings.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text(l10n.appBindingsEmpty, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: p.ink2)), + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: bindings.length, + separatorBuilder: (_, _) => Container(height: 1, color: p.line), + itemBuilder: (context, i) { + final b = bindings[i]; + final accountName = + accountById[b.accountId]?.name ?? b.accountId; + final subtitle = b.cardLast4 != null + ? '•••• ${b.cardLast4}' + : (b.phone ?? l10n.appBindingsAnyCard); + return ListTile( + title: Text(accountName, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text(subtitle, + style: TextStyle(fontSize: 12, color: p.ink2)), + leading: IconButton( + icon: Icon( + b.isDefault ? Icons.star : Icons.star_border, + color: b.isDefault ? p.accent : p.ink2, + ), + tooltip: l10n.appBindingsSetDefault, + onPressed: b.isDefault + ? null + : () => ref + .read(accountBindingsControllerProvider.notifier) + .setDefault(b.id, userId, packageName), + ), + trailing: IconButton( + icon: Icon(Icons.delete_outline, color: p.ink2), + onPressed: () => ref + .read(accountBindingsControllerProvider.notifier) + .delete(b.id), + ), + ); + }, + ), + ); + } + + Future _addBinding( + BuildContext context, + WidgetRef ref, + String userId, + ) async { + final accountId = await showAccountPicker(context, userId: userId); + if (accountId == null || !context.mounted) return; + + final l10n = context.l10n; + final cardCtrl = TextEditingController(); + var asDefault = false; + final ok = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(l10n.appBindingsAddTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: cardCtrl, + keyboardType: TextInputType.number, + maxLength: 4, + decoration: InputDecoration( + labelText: l10n.appBindingsCardLabel, + helperText: l10n.appBindingsCardHelper, + ), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.appBindingsDefaultLabel), + value: asDefault, + onChanged: (v) => setState(() => asDefault = v ?? false), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text(l10n.commonCancel), + ), + TextButton( + onPressed: () => context.pop(true), + child: Text(l10n.commonAdd), + ), + ], + ), + ), + ); + final card = cardCtrl.text.trim(); + cardCtrl.dispose(); + if (ok == true) { + await ref.read(accountBindingsControllerProvider.notifier).add( + userId: userId, + packageName: packageName, + accountId: accountId, + cardLast4: card.isEmpty ? null : card, + isDefault: asDefault, + ); + } + } +} diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart index d226e94..5984aca 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart @@ -72,6 +72,11 @@ class ParsingSettingsScreen extends ConsumerWidget { title: l10n.parsingRulesTile, onTap: () => context.push(AppRoutes.parsingRules), ), + _NavTile( + icon: Icons.apps_outlined, + title: l10n.parsingAppsTile, + onTap: () => context.push(AppRoutes.parsingApps), + ), _NavTile( icon: Icons.receipt_long_outlined, title: l10n.parsingLogTile, diff --git a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart index 82cad8d..8883240 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart @@ -36,6 +36,7 @@ class RuleEditorPrefill { /// Результат редактора в режиме compose (возвращается через pop в Inbox). class RuleEditorResult { const RuleEditorResult({ + required this.kind, required this.matchMode, required this.pattern, required this.merchantCanonical, @@ -44,6 +45,7 @@ class RuleEditorResult { this.priority = 0, }); + final ParseRuleKind kind; final MatchMode matchMode; final String pattern; final String merchantCanonical; @@ -68,6 +70,7 @@ class RuleEditorScreen extends ConsumerStatefulWidget { class _RuleEditorScreenState extends ConsumerState { final _patternCtrl = TextEditingController(); final _merchantCtrl = TextEditingController(); + ParseRuleKind _kind = ParseRuleKind.merchantToCategory; MatchMode _matchMode = MatchMode.contains; String? _categoryId; String? _accountId; @@ -101,6 +104,7 @@ class _RuleEditorScreenState extends ConsumerState { void _hydrateFromRule(ParseRule rule) { if (_initialized) return; + _kind = rule.kind; _patternCtrl.text = rule.pattern; _merchantCtrl.text = rule.merchantCanonical ?? ''; _matchMode = rule.matchMode; @@ -111,18 +115,27 @@ class _RuleEditorScreenState extends ConsumerState { _initialized = true; } + bool get _isSender => _kind == ParseRuleKind.senderToAccount; + + /// Готовность к сохранению: для sender→account нужен счёт. + bool get _canSave => + _patternCtrl.text.trim().isNotEmpty && (!_isSender || _accountId != null); + Future _save(String userId, ParseRule? existing) async { final pattern = _patternCtrl.text.trim(); if (pattern.isEmpty) return; - final merchant = _merchantCtrl.text.trim(); + final merchant = _isSender ? '' : _merchantCtrl.text.trim(); + // sender→account не задаёт категорию/мерчанта. + final categoryId = _isSender ? null : _categoryId; if (_isEdit && existing != null) { await ref.read(rulesControllerProvider.notifier).update( existing.copyWith( + kind: _kind, matchMode: _matchMode, pattern: pattern, merchantCanonical: merchant.isEmpty ? null : merchant, - categoryId: _categoryId, + categoryId: categoryId, accountId: _accountId, priority: _priority, enabled: _enabled, @@ -132,10 +145,11 @@ class _RuleEditorScreenState extends ConsumerState { } else { context.pop( RuleEditorResult( + kind: _kind, matchMode: _matchMode, pattern: pattern, merchantCanonical: merchant, - categoryId: _categoryId, + categoryId: categoryId, accountId: _accountId, priority: _priority, ), @@ -191,6 +205,16 @@ class _RuleEditorScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ + // Выбор вида — только при создании из списка правил. Из Inbox + // (prefill) композим всегда merchant→category: senderToAccount там + // не применяется (inbox_controller создаёт правило этого вида). + if (!_isEdit && widget.prefill == null) ...[ + _KindSelector( + kind: _kind, + onChanged: (k) => setState(() => _kind = k), + ), + const SizedBox(height: 16), + ], Text(l10n.ruleEditorIfContains, style: TextStyle(fontSize: 13, color: p.ink2)), const SizedBox(height: 6), @@ -216,36 +240,41 @@ class _RuleEditorScreenState extends ConsumerState { 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, + if (!_isSender) ...[ + _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), + _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, + value: accountName ?? + (_isSender + ? l10n.ruleEditorAccountPick + : l10n.ruleEditorAccountUnchanged), onTap: () async { final id = await showAccountPicker( context, @@ -298,9 +327,7 @@ class _RuleEditorScreenState extends ConsumerState { backgroundColor: p.accent, minimumSize: const Size.fromHeight(48), ), - onPressed: _patternCtrl.text.trim().isEmpty - ? null - : () => _save(userId, existing), + onPressed: !_canSave ? null : () => _save(userId, existing), child: Text(l10n.ruleEditorSave), ), ], @@ -363,6 +390,33 @@ class _MatchesPreview extends ConsumerWidget { } } +class _KindSelector extends StatelessWidget { + const _KindSelector({required this.kind, required this.onChanged}); + + final ParseRuleKind kind; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return SegmentedButton( + segments: [ + ButtonSegment( + value: ParseRuleKind.merchantToCategory, + label: Text(l10n.ruleKindMerchant), + ), + ButtonSegment( + value: ParseRuleKind.senderToAccount, + label: Text(l10n.ruleKindAccount), + ), + ], + selected: {kind}, + showSelectedIcon: false, + onSelectionChanged: (s) => onChanged(s.first), + ); + } +} + class _MatchModeSelector extends StatelessWidget { const _MatchModeSelector({required this.mode, required this.onChanged}); diff --git a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart index a8e7030..6ac458f 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../accounts/application/accounts_controller.dart'; +import '../../../accounts/domain/entities/account.dart'; import '../../../categories/application/categories_controller.dart'; import '../../../categories/domain/entities/category.dart'; import '../../../user/application/active_user_controller.dart'; @@ -51,6 +53,9 @@ class _RulesListScreenState extends ConsumerState { final categories = ref.watch(categoriesStreamProvider(userId)).value ?? const []; final categoryById = {for (final c in categories) c.id: c}; + final accounts = + ref.watch(accountsStreamProvider(userId)).value ?? const []; + final accountById = {for (final a in accounts) a.id: a}; return Scaffold( backgroundColor: p.paper, @@ -107,6 +112,7 @@ class _RulesListScreenState extends ConsumerState { itemBuilder: (context, i) => RuleCard( rule: rules[i], categoryById: categoryById, + accountById: accountById, onTap: () => context.push(AppRoutes.parsingRuleEdit(rules[i].id)), onToggle: (enabled) => ref @@ -126,7 +132,7 @@ class _RulesListScreenState extends ConsumerState { if (result == null) return; await ref.read(rulesControllerProvider.notifier).create( userId: userId, - kind: ParseRuleKind.merchantToCategory, + kind: result.kind, matchMode: result.matchMode, pattern: result.pattern, priority: result.priority, diff --git a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart new file mode 100644 index 0000000..ce29023 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart @@ -0,0 +1,243 @@ +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/source_apps_controller.dart'; +import '../../data/source_apps/source_apps_catalog.dart'; +import '../../domain/entities/source_app.dart'; + +/// Экран управления приложениями-источниками (allowlist, §A). +/// +/// «Включённые» — мониторятся (toggle + переход к привязкам); «Доступные» — +/// из каталога, ещё не добавлены (тап включает); «Добавить вручную» — +/// кастомный packageName. +class SourceAppsScreen extends ConsumerWidget { + const SourceAppsScreen({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 apps = ref.watch(sourceAppsListProvider(userId)).value ?? + const []; + final addedPackages = {for (final a in apps) a.packageName}; + final available = kSourceAppsCatalog + .where((t) => !addedPackages.contains(t.packageName)) + .toList(); + + return Scaffold( + backgroundColor: p.paper, + appBar: AppBar( + backgroundColor: p.paper, + title: Text(l10n.sourceAppsTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: l10n.sourceAppsAddManual, + onPressed: () => _addManual(context, ref, userId), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + Text(l10n.sourceAppsAddedSection, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + if (apps.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(l10n.sourceAppsEmpty, + style: TextStyle(fontSize: 14, color: p.ink2)), + ) + else + _Card( + children: [ + for (var i = 0; i < apps.length; i++) ...[ + if (i > 0) Container(height: 1, color: p.line), + _AddedAppTile(app: apps[i], userId: userId), + ], + ], + ), + if (available.isNotEmpty) ...[ + const SizedBox(height: 20), + Text(l10n.sourceAppsAvailableSection, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + _Card( + children: [ + for (var i = 0; i < available.length; i++) ...[ + if (i > 0) Container(height: 1, color: p.line), + _AvailableAppTile(template: available[i], userId: userId), + ], + ], + ), + ], + ], + ), + ); + } + + Future _addManual( + BuildContext context, + WidgetRef ref, + String userId, + ) async { + final l10n = context.l10n; + final pkgCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.sourceAppsAddManual), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: pkgCtrl, + autofocus: true, + decoration: + InputDecoration(labelText: l10n.sourceAppsPackageLabel), + ), + const SizedBox(height: 8), + TextField( + controller: nameCtrl, + decoration: InputDecoration(labelText: l10n.sourceAppsNameLabel), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text(l10n.commonCancel), + ), + TextButton( + onPressed: () => context.pop(true), + child: Text(l10n.commonAdd), + ), + ], + ), + ); + final pkg = pkgCtrl.text.trim(); + final name = nameCtrl.text.trim(); + pkgCtrl.dispose(); + nameCtrl.dispose(); + if (result == true && pkg.isNotEmpty) { + await ref.read(sourceAppsControllerProvider.notifier).add( + userId: userId, + packageName: pkg, + displayName: name.isEmpty ? null : name, + ); + } + } +} + +class _AddedAppTile extends ConsumerWidget { + const _AddedAppTile({required this.app, required this.userId}); + + final SourceApp app; + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final l10n = context.l10n; + final ctrl = ref.read(sourceAppsControllerProvider.notifier); + final title = app.displayName ?? app.packageName; + + return InkWell( + onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 8, 8), + child: Row( + children: [ + Icon(Icons.apps_outlined, size: 20, color: p.ink2), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(fontSize: 14, color: p.ink)), + if (app.displayName != null) + Text(app.packageName, + style: TextStyle(fontSize: 11, color: p.ink2)), + ], + ), + ), + Switch( + value: app.enabled, + activeThumbColor: p.accent, + onChanged: (v) => ctrl.setEnabled(app.id, enabled: v), + ), + IconButton( + icon: Icon(Icons.delete_outline, size: 20, color: p.ink2), + tooltip: l10n.commonDelete, + onPressed: () => ctrl.delete(app.id), + ), + Icon(Icons.chevron_right, size: 18, color: p.ink2), + ], + ), + ), + ); + } +} + +class _AvailableAppTile extends ConsumerWidget { + const _AvailableAppTile({required this.template, required this.userId}); + + final SourceAppTemplate template; + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + return InkWell( + onTap: () => ref.read(sourceAppsControllerProvider.notifier).add( + userId: userId, + packageName: template.packageName, + displayName: template.displayName, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + Icon(Icons.add_circle_outline, size: 20, color: p.ink2), + const SizedBox(width: 12), + Expanded( + child: Text(template.displayName, + style: TextStyle(fontSize: 14, color: p.ink)), + ), + ], + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({required this.children}); + final List children; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Container( + decoration: BoxDecoration( + border: Border.all(color: p.line), + borderRadius: BorderRadius.circular(14), + ), + child: Column(children: children), + ); + } +} diff --git a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart index 075931f..fd2c9f2 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../accounts/domain/entities/account.dart'; import '../../../categories/domain/entities/category.dart'; import '../../domain/entities/parse_rule.dart'; import '../../domain/enums.dart'; @@ -12,12 +13,14 @@ class RuleCard extends StatelessWidget { super.key, required this.rule, required this.categoryById, + this.accountById = const {}, required this.onTap, required this.onToggle, }); final ParseRule rule; final Map categoryById; + final Map accountById; final VoidCallback onTap; final ValueChanged onToggle; @@ -34,10 +37,14 @@ class RuleCard extends StatelessWidget { final categoryName = rule.categoryId != null ? categoryById[rule.categoryId]?.name : null; - final target = [ - rule.merchantCanonical, - categoryName, - ].whereType().join(' · '); + final accountName = + rule.accountId != null ? accountById[rule.accountId]?.name : null; + final target = rule.kind == ParseRuleKind.senderToAccount + ? (accountName ?? '') + : [ + rule.merchantCanonical, + categoryName, + ].whereType().join(' · '); return Dismissible( key: ValueKey(rule.id), diff --git a/test/core/database/migration_v6_test.dart b/test/core/database/migration_v6_test.dart index fd3a8b3..3f9d4ec 100644 --- a/test/core/database/migration_v6_test.dart +++ b/test/core/database/migration_v6_test.dart @@ -42,12 +42,16 @@ void main() { ), ); - // 2. «Откатываем» схему до v5: убираем новые колонки и версию. + // 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7 + // (иначе onUpgrade 5→7 попытается создать их повторно). await dbV6.customStatement( 'ALTER TABLE transactions DROP COLUMN obligation'); await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse'); await dbV6.customStatement( 'ALTER TABLE settings DROP COLUMN habit_tracking_enabled'); + await dbV6.customStatement( + 'ALTER TABLE account_bindings DROP COLUMN is_default'); + await dbV6.customStatement('DROP TABLE source_apps'); await dbV6.customStatement('PRAGMA user_version = 5'); await dbV6.close(); diff --git a/test/core/database/migration_v7_test.dart b/test/core/database/migration_v7_test.dart new file mode 100644 index 0000000..b16c8ca --- /dev/null +++ b/test/core/database/migration_v7_test.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; + +/// Тест миграции v6 → v7 (account_bindings.is_default + таблица source_apps). +/// +/// Поднимаем актуальную (v7) схему, «откатываем» до v6 (убираем is_default и +/// source_apps, ставим user_version = 6). Повторное открытие запускает +/// onUpgrade(6 → 7), который должен восстановить колонку и таблицу. +void main() { + late File file; + + setUp(() { + final dir = Directory.systemTemp.createTempSync('nb_migration_v7_test'); + file = File('${dir.path}/test.sqlite'); + }); + + tearDown(() { + if (file.existsSync()) file.deleteSync(); + final parent = file.parent; + if (parent.existsSync()) parent.deleteSync(recursive: true); + }); + + test('onUpgrade 6 → 7 добавляет is_default и таблицу source_apps', () async { + const userId = 'user-1'; + const accountId = 'account-1'; + + // 1. Актуальная схема (v7) + FK-цепочка. + final dbV7 = AppDatabase.forTesting(NativeDatabase(file)); + await dbV7.usersDao + .insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест')); + await dbV7.accountsDao.insertAccount( + AccountsTableCompanion.insert( + id: accountId, + userId: userId, + name: 'Основной', + ), + ); + + // 2. «Откатываем» до v6. + await dbV7.customStatement( + 'ALTER TABLE account_bindings DROP COLUMN is_default'); + await dbV7.customStatement('DROP TABLE source_apps'); + await dbV7.customStatement('PRAGMA user_version = 6'); + await dbV7.close(); + + // 3. Повторное открытие → onUpgrade(6 → 7). + final dbMigrated = AppDatabase.forTesting(NativeDatabase(file)); + addTearDown(dbMigrated.close); + + // 4a. is_default доступна с дефолтом false (round-trip привязки). + await dbMigrated.accountBindingsDao.insert( + AccountBindingsTableCompanion.insert( + id: 'b-1', + userId: userId, + accountId: accountId, + packageName: const Value('ru.sberbankmobile'), + ), + ); + final bindings = + await dbMigrated.accountBindingsDao.findByPackageName( + userId, + 'ru.sberbankmobile', + ); + expect(bindings, hasLength(1)); + expect(bindings.first.isDefault, isFalse); + + // 4b. Таблица source_apps существует и принимает строки. + await dbMigrated.sourceAppsDao.insert( + SourceAppsTableCompanion.insert( + id: 's-1', + userId: userId, + packageName: 'ru.sberbankmobile', + ), + ); + final enabled = await dbMigrated.sourceAppsDao.enabledPackages(userId); + expect(enabled, contains('ru.sberbankmobile')); + }); +} diff --git a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart new file mode 100644 index 0000000..b058a82 --- /dev/null +++ b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/providers/database_provider.dart'; +import 'package:new_budget/src/features/accounts/application/account_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart'; +import 'package:new_budget/src/features/notification_parsing/data/openrouter/openrouter_client.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart'; + +/// Тесты allowlist-фильтра и проводки резолвера в [ParsingWorker] (§A/§B). +/// AI замокан (без сети): MockClient отдаёт фиксированный JSON-ответ модели. + +const _userId = 'u1'; +const _accountId = 'acc-default'; +const _bank = 'ru.sberbankmobile'; + +/// AI-парсер поверх MockClient, который возвращает канонический draft-JSON. +/// [onCall] вызывается при каждом обращении — для проверки «AI не звали». +AiParser _fakeAiParser({ + required String merchantRaw, + required num amount, + void Function()? onCall, +}) { + final mock = MockClient((req) async { + onCall?.call(); + final content = jsonEncode({ + 'type': 'expense', + 'kind': 'purchase', + 'amount': amount, + 'currency': 'RUB', + 'merchantRaw': merchantRaw, + }); + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'content': content}, + } + ], + 'usage': {'total_tokens': 42}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + return AiParser(OpenRouterClient(client: mock, apiKey: 'k')); +} + +Future _seed(AppDatabase db) async { + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + await db.accountsDao.insertAccount( + AccountsTableCompanion.insert( + id: _accountId, + userId: _userId, + name: 'Main', + ), + ); +} + +void _activateWorker(ProviderContainer c) { + c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true); + c.read(parsingWorkerProvider(_userId)); +} + +Future _waitTerminal( + RawMessagesRepository repo, + String id, { + Duration timeout = const Duration(seconds: 10), +}) async { + const transient = { + RawMessageStatus.pending, + RawMessageStatus.parsing, + RawMessageStatus.pendingAi, + }; + final completer = Completer(); + late final StreamSubscription> sub; + sub = repo.watchAll(_userId).listen((list) { + for (final m in list) { + if (m.id == id && !transient.contains(m.status)) { + if (!completer.isCompleted) completer.complete(m); + return; + } + } + }); + try { + return await completer.future.timeout(timeout); + } finally { + await sub.cancel(); + } +} + +void main() { + late AppDatabase db; + late ProviderContainer container; + late RawMessagesRepository repo; + + Future enableAi(ProviderContainer c) async { + final settings = c.read(parsingSettingsControllerProvider.notifier); + await c.read(parsingSettingsControllerProvider.future); + await settings.setAiConsent(true); + } + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + test('сообщение от пакета НЕ из allowlist → ignored, AI не вызывается', + () async { + var aiCalled = false; + db = AppDatabase.forTesting(NativeDatabase.memory()); + await _seed(db); + // НИ одной строки source_apps → пакет не в allowlist. + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + isOnlineProvider.overrideWith((ref) => Stream.value(true)), + aiParserProvider.overrideWith((ref) async => _fakeAiParser( + merchantRaw: 'LENTA', + amount: 1500, + onCall: () => aiCalled = true, + )), + ]); + repo = container.read(rawMessagesRepositoryProvider); + await enableAi(container); + _activateWorker(container); + + final inserted = await repo.insertIncoming( + userId: _userId, + packageName: 'com.random.chat', + body: 'Payment of 1500 RUB at LENTA', + receivedAt: DateTime(2026, 5, 31, 12), + ); + + final msg = await _waitTerminal(repo, inserted.id); + expect(msg.status, RawMessageStatus.ignored); + expect(aiCalled, isFalse, reason: 'AI must not be called for non-allowlisted package'); + }); + + test( + 'включённый банк без карты + глобальный дефолт + правило категории ' + '→ авто-применение на дефолтный счёт', () async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await _seed(db); + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + isOnlineProvider.overrideWith((ref) => Stream.value(true)), + aiParserProvider.overrideWith((ref) async => + _fakeAiParser(merchantRaw: 'LENTA', amount: 1500)), + ]); + repo = container.read(rawMessagesRepositoryProvider); + await enableAi(container); + + // Allowlist: банк включён. + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-1', + userId: _userId, + packageName: _bank, + )); + // Глобальный дефолтный счёт. + await container + .read(accountRepositoryProvider) + .setDefault(_accountId, _userId); + // Категория + правило merchant→category на «LENTA». + await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert( + id: 'cat-1', + userId: _userId, + name: 'Продукты', + )); + await container.read(parseRulesRepositoryProvider).create( + userId: _userId, + kind: ParseRuleKind.merchantToCategory, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + // AI draft amount score = 60; снижаем строгость, чтобы пройти gate. + await container + .read(parsingSettingsControllerProvider.notifier) + .setStrictness(50); + + _activateWorker(container); + + final inserted = await repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: 'Payment of 1500 RUB at LENTA', + receivedAt: DateTime(2026, 5, 31, 12), + ); + + final msg = await _waitTerminal(repo, inserted.id); + expect(msg.status, RawMessageStatus.applied, + reason: 'lastParseError=${msg.lastParseError}'); + + // Создалась транзакция на дефолтном счёте. + final txns = await db.select(db.transactionsTable).get(); + expect(txns, hasLength(1)); + expect(txns.first.accountId, _accountId); + expect(txns.first.categoryId, 'cat-1'); + }); +} diff --git a/test/features/notification_parsing/integration/ai_processing_integration_test.dart b/test/features/notification_parsing/integration/ai_processing_integration_test.dart index ec722e9..978500f 100644 --- a/test/features/notification_parsing/integration/ai_processing_integration_test.dart +++ b/test/features/notification_parsing/integration/ai_processing_integration_test.dart @@ -82,6 +82,15 @@ Future _seed(AppDatabase db, {List categories = const []}) async { name: 'Основной', ), ); + // Allowlist: тестовый пакет должен быть включён, иначе воркер пометит + // сообщение `ignored` ещё до AI (§A). + await db.sourceAppsDao.insert( + SourceAppsTableCompanion.insert( + id: 'src-1', + userId: _userId, + packageName: 'com.example.bank', + ), + ); var i = 0; for (final name in categories) { await db.categoriesDao.insertCategory( diff --git a/test/features/notification_parsing/parser/account_resolver_test.dart b/test/features/notification_parsing/parser/account_resolver_test.dart new file mode 100644 index 0000000..c6dcfdc --- /dev/null +++ b/test/features/notification_parsing/parser/account_resolver_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/account_binding.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart'; + +const _userId = 'u1'; +const _pkg = 'ru.sberbankmobile'; +final _now = DateTime(2026, 1, 1); + +AccountBinding _binding({ + required String id, + required String accountId, + String? cardLast4, + String? phone, + bool isDefault = false, +}) => + AccountBinding( + id: id, + userId: _userId, + packageName: _pkg, + cardLast4: cardLast4, + phone: phone, + accountId: accountId, + isDefault: isDefault, + createdAt: _now, + ); + +ParseRule _senderRule(String pattern, String accountId) => ParseRule( + id: 'sr', + userId: _userId, + kind: ParseRuleKind.senderToAccount, + matchMode: MatchMode.contains, + pattern: pattern, + accountId: accountId, + createdAt: _now, + ); + +/// Конфигурируемый фейк: возвращает заданные привязки из in-memory списка. +class _FakeBindingsRepo implements AccountBindingsRepository { + _FakeBindingsRepo(this.bindings); + final List bindings; + + @override + Future findByPackageAndCard( + String userId, + String packageName, + String cardLast4, + ) async => + bindings + .where((b) => + b.packageName == packageName && b.cardLast4 == cardLast4) + .firstOrNull; + + @override + Future findByPhone(String userId, String phone) async => + bindings.where((b) => b.phone == phone).firstOrNull; + + @override + Future> findByPackageName( + String userId, + String packageName, + ) async => + bindings.where((b) => b.packageName == packageName).toList(); + + @override + Future findDefaultByPackageName( + String userId, + String packageName, + ) async => + bindings + .where((b) => b.packageName == packageName && b.isDefault) + .firstOrNull; + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +Future _resolve( + List bindings, { + String? cardLast4, + String? phone, + List senderRules = const [], + String? globalDefaultAccountId, + String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA', +}) { + final resolver = AccountResolver(_FakeBindingsRepo(bindings)); + return resolver.resolve( + userId: _userId, + packageName: _pkg, + body: body, + cardLast4: cardLast4, + phone: phone, + senderRules: senderRules, + globalDefaultAccountId: globalDefaultAccountId, + ); +} + +void main() { + group('AccountResolver.resolve (table B)', () { + test('#1 binding by package + card → 100, trusted', () async { + final r = await _resolve( + [_binding(id: 'b', accountId: 'acc-card', cardLast4: '3456')], + cardLast4: '3456', + ); + expect(r.accountId, 'acc-card'); + expect(r.score, 100); + expect(r.trusted, isTrue); + expect(r.source, AccountSource.bindingCard); + }); + + test('#1 binding by phone → 100, trusted', () async { + final r = await _resolve( + [_binding(id: 'b', accountId: 'acc-phone', phone: '+79990001122')], + phone: '+79990001122', + ); + expect(r.accountId, 'acc-phone'); + expect(r.score, 100); + expect(r.trusted, isTrue); + expect(r.source, AccountSource.bindingPhone); + }); + + test('#2 senderToAccount rule → 90, trusted', () async { + // Несколько привязок без default, чтобы single/default не перехватили, + // но правило отправителя имеет приоритет над ними. + final r = await _resolve( + [ + _binding(id: 'b1', accountId: 'a1'), + _binding(id: 'b2', accountId: 'a2'), + ], + senderRules: [_senderRule('PYATEROCHKA', 'acc-rule')], + ); + expect(r.accountId, 'acc-rule'); + expect(r.score, 90); + expect(r.trusted, isTrue); + expect(r.source, AccountSource.senderRule); + }); + + test('#3 single binding by package → 75, trusted', () async { + final r = await _resolve([_binding(id: 'b', accountId: 'acc-single')]); + expect(r.accountId, 'acc-single'); + expect(r.score, 75); + expect(r.trusted, isTrue); + expect(r.source, AccountSource.singleBinding); + }); + + test('#4 per-app default binding → 70, trusted', () async { + final r = await _resolve([ + _binding(id: 'b1', accountId: 'a1'), + _binding(id: 'b2', accountId: 'acc-default', isDefault: true), + ]); + expect(r.accountId, 'acc-default'); + expect(r.score, 70); + expect(r.trusted, isTrue); + expect(r.source, AccountSource.appDefault); + }); + + test('#5 multiple bindings, no default → 45, NOT trusted', () async { + final r = await _resolve([ + _binding(id: 'b1', accountId: 'a1'), + _binding(id: 'b2', accountId: 'a2'), + ]); + expect(r.accountId, 'a1'); + expect(r.score, 45); + expect(r.trusted, isFalse); + expect(r.source, AccountSource.ambiguous); + }); + + test('#6 global default account → 40, trusted', () async { + final r = await _resolve(const [], globalDefaultAccountId: 'acc-global'); + expect(r.accountId, 'acc-global'); + expect(r.score, 40); + expect(r.trusted, isTrue); + expect(r.source, AccountSource.globalDefault); + }); + + test('#7 nothing → null, 15, NOT trusted', () async { + final r = await _resolve(const []); + expect(r.accountId, isNull); + expect(r.score, 15); + expect(r.trusted, isFalse); + expect(r.source, AccountSource.none); + }); + }); +} diff --git a/test/features/notification_parsing/parser/decision_gate_test.dart b/test/features/notification_parsing/parser/decision_gate_test.dart index 8438927..82f2812 100644 --- a/test/features/notification_parsing/parser/decision_gate_test.dart +++ b/test/features/notification_parsing/parser/decision_gate_test.dart @@ -24,14 +24,15 @@ const _strongScores = FieldScores( void main() { group('decide', () { - test('rule + sanity + scores ≥ strictness → autoApply', () { + test('rule + sanity + scores ≥ strictness + trusted → autoApply', () { final d = decide( merchantRule: _rule(), sanityPassed: true, scores: _strongScores, strictness: 85, amountMinor: 124000, - hasAccount: true, + accountResolved: true, + accountTrusted: true, ); expect(d, GateDecision.autoApply); }); @@ -43,7 +44,8 @@ void main() { scores: _strongScores, strictness: 85, amountMinor: 124000, - hasAccount: true, + accountResolved: true, + accountTrusted: true, ); expect(d, GateDecision.inbox); }); @@ -53,15 +55,16 @@ void main() { merchantRule: _rule(), sanityPassed: true, scores: const FieldScores( - amount: 100, - account: 45, + amount: 45, + account: 100, type: 100, merchant: 100, category: 100, ), strictness: 85, amountMinor: 124000, - hasAccount: true, + accountResolved: true, + accountTrusted: true, ); expect(d, GateDecision.inbox); }); @@ -73,7 +76,21 @@ void main() { scores: _strongScores, strictness: 85, amountMinor: 124000, - hasAccount: false, + accountResolved: false, + accountTrusted: false, + ); + expect(d, GateDecision.inbox); + }); + + test('untrusted account (ambiguous multi-binding) → inbox', () { + final d = decide( + merchantRule: _rule(), + sanityPassed: true, + scores: _strongScores, + strictness: 85, + amountMinor: 124000, + accountResolved: true, + accountTrusted: false, ); expect(d, GateDecision.inbox); }); @@ -85,7 +102,8 @@ void main() { scores: _strongScores, strictness: 85, amountMinor: 100001 * 100, - hasAccount: true, + accountResolved: true, + accountTrusted: true, ); expect(d, GateDecision.inbox); }); @@ -97,7 +115,8 @@ void main() { scores: _strongScores, strictness: 85, amountMinor: 124000, - hasAccount: true, + accountResolved: true, + accountTrusted: true, ); expect(d, GateDecision.inbox); }); diff --git a/test/features/notification_parsing/parser/rule_lookup_test.dart b/test/features/notification_parsing/parser/rule_lookup_test.dart index 3c78399..8892409 100644 --- a/test/features/notification_parsing/parser/rule_lookup_test.dart +++ b/test/features/notification_parsing/parser/rule_lookup_test.dart @@ -112,6 +112,48 @@ void main() { }); }); + group('findSenderRule', () { + const body = 'Покупка PYATEROCHKA 1240'; + + ParseRule sender({ + required String id, + required String pattern, + String? accountId = 'acc1', + bool enabled = true, + }) => + _rule( + id: id, + pattern: pattern, + kind: ParseRuleKind.senderToAccount, + enabled: enabled, + categoryId: null, + ).copyWith(accountId: accountId); + + test('returns matching enabled sender rule', () { + final r = findSenderRule( + [sender(id: 's', pattern: 'PYATEROCHKA')], + body: body, + ); + expect(r?.id, 's'); + }); + + test('ignores rules without accountId', () { + final r = findSenderRule( + [sender(id: 's', pattern: 'PYATEROCHKA', accountId: null)], + body: body, + ); + expect(r, isNull); + }); + + test('ignores merchant-kind rules', () { + final r = findSenderRule( + [_rule(id: 'm', pattern: 'PYATEROCHKA')], + body: body, + ); + expect(r, isNull); + }); + }); + group('findIgnoreRule', () { test('finds enabled ignore rule', () { final r = findIgnoreRule(