Add account bind
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
+28
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'Выбрать счёт';
|
||||
}
|
||||
|
||||
+28
-1
@@ -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": "Выбрать счёт"
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<List<AccountBinding>> accountBindingsList(Ref ref, String userId) =>
|
||||
ref.watch(accountBindingsRepositoryProvider).watchByUser(userId);
|
||||
|
||||
/// CRUD над привязками «карта/телефон → счёт».
|
||||
@Riverpod(keepAlive: true)
|
||||
class AccountBindingsController extends _$AccountBindingsController {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
|
||||
Future<void> 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<void> setDefault(String id, String userId, String packageName) => ref
|
||||
.read(accountBindingsRepositoryProvider)
|
||||
.setDefault(id, userId, packageName);
|
||||
|
||||
Future<void> delete(String id) =>
|
||||
ref.read(accountBindingsRepositoryProvider).deleteById(id);
|
||||
}
|
||||
@@ -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<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
|
||||
ref.watch(sourceAppsRepositoryProvider).enabledPackages(userId);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<List<SourceApp>> sourceAppsList(Ref ref, String userId) =>
|
||||
ref.watch(sourceAppsRepositoryProvider).watchByUser(userId);
|
||||
|
||||
/// CRUD над приложениями-источниками (allowlist).
|
||||
@Riverpod(keepAlive: true)
|
||||
class SourceAppsController extends _$SourceAppsController {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
|
||||
Future<void> 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<void> setEnabled(String id, {required bool enabled}) =>
|
||||
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled);
|
||||
|
||||
Future<void> delete(String id) =>
|
||||
ref.read(sourceAppsRepositoryProvider).deleteById(id);
|
||||
}
|
||||
@@ -69,6 +69,19 @@ class AccountBindingsDao extends DatabaseAccessor<AppDatabase>
|
||||
t.userId.equals(userId) & t.packageName.equals(packageName)))
|
||||
.get();
|
||||
|
||||
/// Умолчательная привязка приложения (card неизвестна) — §B #4.
|
||||
Future<AccountBindingsTableData?> 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<void> insert(AccountBindingsTableCompanion companion) =>
|
||||
@@ -86,6 +99,22 @@ class AccountBindingsDao extends DatabaseAccessor<AppDatabase>
|
||||
updates: {accountBindingsTable},
|
||||
);
|
||||
|
||||
/// Делает привязку [id] умолчательной для её пакета: снимает флаг у всех
|
||||
/// прочих привязок этого packageName и выставляет у выбранной (атомарно).
|
||||
Future<void> 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<int> deleteById(String id) =>
|
||||
(delete(accountBindingsTable)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
@@ -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<AppDatabase>
|
||||
with _$SourceAppsDaoMixin {
|
||||
SourceAppsDao(super.db);
|
||||
|
||||
/// Все приложения пользователя — для экрана управления.
|
||||
Stream<List<SourceAppsTableData>> watchByUser(String userId) =>
|
||||
(select(sourceAppsTable)
|
||||
..where((t) => t.userId.equals(userId))
|
||||
..orderBy([(t) => OrderingTerm(expression: t.displayName)]))
|
||||
.watch();
|
||||
|
||||
/// Включённые packageName — для allowlist-фильтра.
|
||||
Future<List<String>> 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<SourceAppsTableData?> findByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
) =>
|
||||
(select(sourceAppsTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) & t.packageName.equals(packageName))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
Future<SourceAppsTableData?> findById(String id) =>
|
||||
(select(sourceAppsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
|
||||
Future<void> insert(SourceAppsTableCompanion companion) =>
|
||||
into(sourceAppsTable).insert(companion);
|
||||
|
||||
Future<void> setEnabled(String id, {required bool enabled}) =>
|
||||
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||
.write(SourceAppsTableCompanion(enabled: Value(enabled)));
|
||||
|
||||
Future<int> deleteById(String id) =>
|
||||
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
@@ -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)();
|
||||
|
||||
|
||||
@@ -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<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<Set<Column>> get uniqueKeys => [
|
||||
{userId, packageName},
|
||||
];
|
||||
}
|
||||
@@ -11,6 +11,7 @@ extension AccountBindingMapper on AccountBindingsTableData {
|
||||
cardLast4: cardLast4,
|
||||
phone: phone,
|
||||
accountId: accountId,
|
||||
isDefault: isDefault,
|
||||
matchCount: matchCount,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<AccountResolution> resolve({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
required String body,
|
||||
String? cardLast4,
|
||||
String? phone,
|
||||
String? merchantRaw,
|
||||
List<ParseRule> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -56,6 +56,25 @@ ParseRule? findMerchantRule(
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
/// Ищет активное правило `senderToAccount`, матчащее тело сообщения (§B #2).
|
||||
///
|
||||
/// При конфликте — та же специфичность, что у [findMerchantRule].
|
||||
ParseRule? findSenderRule(
|
||||
List<ParseRule> 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<ParseRule> rules, {
|
||||
|
||||
+13
@@ -46,6 +46,13 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository {
|
||||
.map((r) => r.toDomain())
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findDefaultByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
) async =>
|
||||
(await _dao.findDefaultByPackageName(userId, packageName))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<AccountBinding> 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<void> incrementMatchCount(String id) => _dao.incrementMatchCount(id);
|
||||
|
||||
@override
|
||||
Future<void> setDefault(String id, String userId, String packageName) =>
|
||||
_dao.setDefault(id, userId, packageName);
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||
}
|
||||
|
||||
+51
@@ -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<List<SourceApp>> watchByUser(String userId) => _dao
|
||||
.watchByUser(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Future<Set<String>> enabledPackages(String userId) async =>
|
||||
(await _dao.enabledPackages(userId)).toSet();
|
||||
|
||||
@override
|
||||
Future<SourceApp> 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<void> setEnabled(String id, {required bool enabled}) =>
|
||||
_dao.setEnabled(id, enabled: enabled);
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||
}
|
||||
@@ -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<SourceAppTemplate> 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,
|
||||
),
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+9
@@ -25,6 +25,12 @@ abstract interface class AccountBindingsRepository {
|
||||
String packageName,
|
||||
);
|
||||
|
||||
/// Умолчательная привязка приложения (карта не распознана) — §B #4.
|
||||
Future<AccountBinding?> findDefaultByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
);
|
||||
|
||||
Future<AccountBinding> create({
|
||||
required String userId,
|
||||
String? packageName,
|
||||
@@ -36,5 +42,8 @@ abstract interface class AccountBindingsRepository {
|
||||
|
||||
Future<void> incrementMatchCount(String id);
|
||||
|
||||
/// Делает привязку умолчательной для её пакета (сбрасывает флаг у прочих).
|
||||
Future<void> setDefault(String id, String userId, String packageName);
|
||||
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import '../entities/source_app.dart';
|
||||
|
||||
/// Доступ к приложениям-источникам уведомлений ([SourceApp]) — allowlist.
|
||||
abstract interface class SourceAppsRepository {
|
||||
/// Все приложения-источники пользователя — для экрана управления.
|
||||
Stream<List<SourceApp>> watchByUser(String userId);
|
||||
|
||||
/// Множество включённых packageName — для allowlist-фильтра в пайплайне.
|
||||
Future<Set<String>> enabledPackages(String userId);
|
||||
|
||||
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
|
||||
/// Если строка с таким packageName уже есть — возвращает существующую.
|
||||
Future<SourceApp> add({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
String? displayName,
|
||||
});
|
||||
|
||||
Future<void> setEnabled(String id, {required bool enabled});
|
||||
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
@@ -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 <AccountBinding>[])
|
||||
.where((b) => b.packageName == packageName)
|
||||
.toList();
|
||||
final accounts = ref.watch(accountsStreamProvider(userId)).value ??
|
||||
const <Account>[];
|
||||
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<void> _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<bool>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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,
|
||||
|
||||
+86
-32
@@ -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<RuleEditorScreen> {
|
||||
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<RuleEditorScreen> {
|
||||
|
||||
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<RuleEditorScreen> {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
bool get _isSender => _kind == ParseRuleKind.senderToAccount;
|
||||
|
||||
/// Готовность к сохранению: для sender→account нужен счёт.
|
||||
bool get _canSave =>
|
||||
_patternCtrl.text.trim().isNotEmpty && (!_isSender || _accountId != null);
|
||||
|
||||
Future<void> _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<RuleEditorScreen> {
|
||||
} 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<RuleEditorScreen> {
|
||||
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<RuleEditorScreen> {
|
||||
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<RuleEditorScreen> {
|
||||
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<ParseRuleKind> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return SegmentedButton<ParseRuleKind>(
|
||||
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});
|
||||
|
||||
|
||||
@@ -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<RulesListScreen> {
|
||||
final categories =
|
||||
ref.watch(categoriesStreamProvider(userId)).value ?? const <Category>[];
|
||||
final categoryById = {for (final c in categories) c.id: c};
|
||||
final accounts =
|
||||
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
|
||||
final accountById = {for (final a in accounts) a.id: a};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
@@ -107,6 +112,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
||||
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<RulesListScreen> {
|
||||
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,
|
||||
|
||||
@@ -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 <SourceApp>[];
|
||||
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<void> _addManual(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String userId,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
final pkgCtrl = TextEditingController();
|
||||
final nameCtrl = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
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<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Category> categoryById;
|
||||
final Map<String, Account> accountById;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<bool> 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<String>().join(' · ');
|
||||
final accountName =
|
||||
rule.accountId != null ? accountById[rule.accountId]?.name : null;
|
||||
final target = rule.kind == ParseRuleKind.senderToAccount
|
||||
? (accountName ?? '')
|
||||
: [
|
||||
rule.merchantCanonical,
|
||||
categoryName,
|
||||
].whereType<String>().join(' · ');
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(rule.id),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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<void> _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<RawMessage> _waitTerminal(
|
||||
RawMessagesRepository repo,
|
||||
String id, {
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
const transient = {
|
||||
RawMessageStatus.pending,
|
||||
RawMessageStatus.parsing,
|
||||
RawMessageStatus.pendingAi,
|
||||
};
|
||||
final completer = Completer<RawMessage>();
|
||||
late final StreamSubscription<List<RawMessage>> 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<void> 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<bool>.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<bool>.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');
|
||||
});
|
||||
}
|
||||
@@ -82,6 +82,15 @@ Future<void> _seed(AppDatabase db, {List<String> 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(
|
||||
|
||||
@@ -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<AccountBinding> bindings;
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByPackageAndCard(
|
||||
String userId,
|
||||
String packageName,
|
||||
String cardLast4,
|
||||
) async =>
|
||||
bindings
|
||||
.where((b) =>
|
||||
b.packageName == packageName && b.cardLast4 == cardLast4)
|
||||
.firstOrNull;
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> findByPhone(String userId, String phone) async =>
|
||||
bindings.where((b) => b.phone == phone).firstOrNull;
|
||||
|
||||
@override
|
||||
Future<List<AccountBinding>> findByPackageName(
|
||||
String userId,
|
||||
String packageName,
|
||||
) async =>
|
||||
bindings.where((b) => b.packageName == packageName).toList();
|
||||
|
||||
@override
|
||||
Future<AccountBinding?> 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<AccountResolution> _resolve(
|
||||
List<AccountBinding> bindings, {
|
||||
String? cardLast4,
|
||||
String? phone,
|
||||
List<ParseRule> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user