From 76f301b758df38c3e55276ca3d6bbc2af32534c3 Mon Sep 17 00:00:00 2001 From: Sanders Date: Sun, 28 Jun 2026 00:08:18 +0300 Subject: [PATCH] Add native notification ingest and source-app allowlist - Android NotificationListenerService plugin + queue store, MethodChannel bridge, and Flutter ingest worker / access controller - source_apps allowlist (DAO, repo, settings + source_apps screen) - schema v8 migration + decision_gate / dedup / draft_codec updates - l10n strings and tests Co-Authored-By: Claude Opus 4.8 --- .claude/settings.local.json | 3 +- CLAUDE.md | 17 +- android/app/src/main/AndroidManifest.xml | 19 + .../sanders/budget/new_budget/MainActivity.kt | 9 +- .../notifications/NotificationIngestPlugin.kt | 176 ++++++++++ .../NotificationIngestService.kt | 41 +++ .../notifications/NotificationQueueStore.kt | 83 +++++ android/app/src/main/res/values/strings.xml | 4 + docs/TEST_PLAN.md | 332 ++++++++++++++++++ docs/notification_parsing_status.md | 2 +- lib/l10n/app_en.arb | 26 +- lib/l10n/app_localizations.dart | 120 ++++++- lib/l10n/app_localizations_en.dart | 62 +++- lib/l10n/app_localizations_ru.dart | 61 +++- lib/l10n/app_ru.arb | 26 +- lib/src/core/database/app_database.dart | 7 +- .../application/inbox_controller.dart | 1 + .../notification_access_controller.dart | 24 ++ .../notification_ingest_worker.dart | 88 +++++ .../notification_parsing_providers.dart | 22 +- .../application/parsing_pipeline.dart | 31 +- .../parsing_settings_controller.dart | 30 +- .../data/drift/daos/raw_messages_dao.dart | 11 +- .../data/drift/daos/source_apps_dao.dart | 14 +- .../data/drift/tables/parse_rules_table.dart | 7 + .../data/drift/tables/raw_messages_table.dart | 4 +- .../data/mappers/parse_rule_mapper.dart | 1 + .../native/notification_listener_channel.dart | 111 ++++++ .../data/parser/confidence_scorer.dart | 25 +- .../data/parser/decision_gate.dart | 96 +++-- .../data/parser/dedup.dart | 12 +- .../data/parser/draft_codec.dart | 28 +- .../parse_rules_repository_impl.dart | 4 + .../raw_messages_repository_impl.dart | 17 +- .../source_apps_repository_impl.dart | 4 +- .../domain/entities/parse_rule.dart | 5 + .../repositories/parse_rules_repository.dart | 2 + .../repositories/raw_messages_repository.dart | 10 +- .../repositories/source_apps_repository.dart | 3 +- .../screens/parsing_log_screen.dart | 8 + .../screens/parsing_settings_screen.dart | 107 ++++-- .../screens/source_apps_screen.dart | 179 ++++++++++ .../widgets/gate_check_labels.dart | 40 +++ .../presentation/widgets/inbox_card.dart | 8 + lib/src/shared/widgets/app_scaffold.dart | 6 + test/core/database/migration_v6_test.dart | 5 +- test/core/database/migration_v7_test.dart | 6 +- test/core/database/migration_v8_test.dart | 73 ++++ .../application/inbox_controller_test.dart | 5 + .../parsing_worker_allowlist_test.dart | 48 ++- .../data/raw_messages_dedup_test.dart | 75 ++++ .../parser/confidence_scorer_test.dart | 1 - .../parser/decision_gate_test.dart | 237 +++++++++---- 53 files changed, 2090 insertions(+), 246 deletions(-) create mode 100644 android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestPlugin.kt create mode 100644 android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestService.kt create mode 100644 android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationQueueStore.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 docs/TEST_PLAN.md create mode 100644 lib/src/features/notification_parsing/application/notification_access_controller.dart create mode 100644 lib/src/features/notification_parsing/application/notification_ingest_worker.dart create mode 100644 lib/src/features/notification_parsing/data/native/notification_listener_channel.dart create mode 100644 lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart create mode 100644 test/core/database/migration_v8_test.dart create mode 100644 test/features/notification_parsing/data/raw_messages_dedup_test.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 67f9ca2..3a08f94 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,8 @@ "Bash(find \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\" -name \"*.gradle\" 2>/dev/null | head -5)", "Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\flutter.gradle\")", "Bash(grep -r \"compileSdk\\\\|minSdk\\\\|targetSdk\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\")", - "Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")" + "Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")", + "Bash(xargs wc -l)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 58fdf8f..f9d92c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ lib/ theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative) theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now core/ - database/app_database.dart # @DriftDatabase, schemaVersion=7 + database/app_database.dart # @DriftDatabase, schemaVersion=8 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,11 +105,19 @@ 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` (+`isDefault` per-app default binding), +`parse_rules` (+`txType` — transaction type pinned at rule creation, checked by the gate), +`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`). +**Auto-apply gate** (`data/parser/decision_gate.dart`): no numeric confidence threshold — +a checklist of named `AutoApplyCheck`s (rule matched, amount literally found in body, +currency known, draft type == rule `txType` (null = skip), account resolved+trusted, +amount ≤ 100 000 ₽), gated by the `autoApplyEnabled` settings toggle. Failed checks are +cached in `draftJson` (`DraftBundle.failedChecks`) and shown as "why not automatic" in the +inbox card / parsing log. Numeric per-field scores remain only for the "?" badge in Inbox. + Enums live alongside their Drift tables; `enum_converters.dart` is the single import point for UI. ## Code-gen gotchas @@ -174,8 +182,9 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe (key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider` with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert - the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. AI-sourced - drafts never auto-apply (amount confidence ≤ 60 < strictness 85) — expect `inbox`. + the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. Drafts for + merchants WITHOUT a user rule never auto-apply (gate check `ruleMatched` fails) — expect + `inbox`; with a rule + amount present in the body + trusted account the gate auto-applies. ## What's left (priority order) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e65c06c..8a13ba6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,19 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt b/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt index 49b04a1..b3fbb2c 100644 --- a/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt +++ b/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt @@ -1,5 +1,12 @@ package com.sanders.budget.new_budget +import com.sanders.budget.new_budget.notifications.NotificationIngestPlugin import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + NotificationIngestPlugin.register(flutterEngine, this) + } +} diff --git a/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestPlugin.kt b/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestPlugin.kt new file mode 100644 index 0000000..8b23503 --- /dev/null +++ b/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestPlugin.kt @@ -0,0 +1,176 @@ +package com.sanders.budget.new_budget.notifications + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream + +/** + * Регистрирует платформенные каналы между Dart и нативным слоем уведомлений. + * + * - MethodChannel [METHOD_CHANNEL]: проверка/выдача разрешения, синк allowlist, + * дрейн очереди. + * - EventChannel [EVENT_CHANNEL]: сигнал-тик «появилось новое уведомление, + * продренируй очередь» (без payload — данные всегда идут через `drainPending`). + */ +object NotificationIngestPlugin { + + private const val METHOD_CHANNEL = "com.sanders.budget/notifications" + private const val EVENT_CHANNEL = "com.sanders.budget/notifications/events" + + fun register(engine: FlutterEngine, context: Context) { + val appContext = context.applicationContext + val store = NotificationQueueStore(appContext) + + MethodChannel(engine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "isPermissionGranted" -> { + val flat = Settings.Secure.getString( + appContext.contentResolver, + "enabled_notification_listeners", + ) + val granted = flat != null && flat.split(":").any { + val cn = ComponentName.unflattenFromString(it) + cn != null && cn.packageName == appContext.packageName + } + result.success(granted) + } + + "openSettings" -> { + val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(intent) + result.success(null) + } + + "setMonitoredPackages" -> { + @Suppress("UNCHECKED_CAST") + val packages = (call.arguments as? List) ?: emptyList() + store.setMonitoredPackages(packages.toSet()) + result.success(null) + } + + "getInstalledApps" -> { + // Тяжёлый I/O (загрузка иконок) — уводим с main-потока, + // результат резолвим обратно на main (требование канала). + val mainHandler = Handler(Looper.getMainLooper()) + Thread { + val apps = loadLaunchableApps(appContext) + mainHandler.post { result.success(apps) } + }.start() + } + + "drainPending" -> { + val array = store.drainAll() + val out = ArrayList>(array.length()) + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + out.add( + mapOf( + NotificationQueueStore.KEY_PACKAGE to + obj.optString(NotificationQueueStore.KEY_PACKAGE), + NotificationQueueStore.KEY_TITLE to + if (obj.isNull(NotificationQueueStore.KEY_TITLE)) null + else obj.optString(NotificationQueueStore.KEY_TITLE), + NotificationQueueStore.KEY_BODY to + obj.optString(NotificationQueueStore.KEY_BODY), + NotificationQueueStore.KEY_RECEIVED_AT to + obj.optLong(NotificationQueueStore.KEY_RECEIVED_AT), + ) + ) + } + result.success(out) + } + + else -> result.notImplemented() + } + } + + EventChannel(engine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + LiveSink.sink = events + } + + override fun onCancel(arguments: Any?) { + LiveSink.sink = null + } + }) + } + + /** + * Список запускаемых приложений (intent MAIN/LAUNCHER) для экрана выбора + * источников. Возвращает packageName, человекочитаемое имя и иконку (PNG-байты). + * Собственный пакет исключаем, дубли по packageName схлопываем, сортируем по + * имени. Запросы видимости пакетов объявлены в `` манифеста, поэтому + * `QUERY_ALL_PACKAGES` не требуется. + */ + private fun loadLaunchableApps(context: Context): List> { + val pm = context.packageManager + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val resolved = pm.queryIntentActivities(intent, 0) + + val byPackage = LinkedHashMap>() + for (info in resolved) { + val pkg = info.activityInfo?.packageName ?: continue + if (pkg == context.packageName) continue + if (byPackage.containsKey(pkg)) continue + val label = info.loadLabel(pm)?.toString().orEmpty().ifEmpty { pkg } + val icon = runCatching { drawableToPng(info.loadIcon(pm)) }.getOrNull() + byPackage[pkg] = mapOf( + "packageName" to pkg, + "appName" to label, + "icon" to icon, + ) + } + return byPackage.values.sortedBy { + (it["appName"] as? String).orEmpty().lowercase() + } + } + + /** Рендерит drawable иконку (вкл. adaptive) в PNG-байты фиксированного размера. */ + private fun drawableToPng(drawable: Drawable?, sizePx: Int = 96): ByteArray? { + if (drawable == null) return null + val bitmap = if (drawable is BitmapDrawable && drawable.bitmap != null) { + Bitmap.createScaledBitmap(drawable.bitmap, sizePx, sizePx, true) + } else { + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bmp + } + return ByteArrayOutputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + out.toByteArray() + } + } +} + +/** + * Держатель живого EventChannel sink. Сервис работает вне UI-потока, поэтому + * тик отправляется через main-handler. Когда приложение закрыто, [sink] = null + * и тик просто игнорируется (очередь дождётся дрейна). + */ +object LiveSink { + @Volatile + var sink: EventChannel.EventSink? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun tick() { + val s = sink ?: return + mainHandler.post { s.success(1) } + } +} diff --git a/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestService.kt b/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestService.kt new file mode 100644 index 0000000..8e4e590 --- /dev/null +++ b/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationIngestService.kt @@ -0,0 +1,41 @@ +package com.sanders.budget.new_budget.notifications + +import android.app.Notification +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification + +/** + * Android NotificationListenerService — нативный источник для pipeline парсинга. + * + * Ловит уведомления, префильтрует по allowlist мониторимых пакетов (синк из + * Dart), складывает в [NotificationQueueStore] и — если приложение открыто и + * слушает EventChannel — будит Dart сигналом-тиком, чтобы он сразу дренировал + * очередь. Очередь остаётся источником истины: если движок выключен, тик + * теряется, но уведомление дождётся дрейна на следующем старте/resume. + */ +class NotificationIngestService : NotificationListenerService() { + + private val store by lazy { NotificationQueueStore(applicationContext) } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + val notification = sbn?.notification ?: return + val packageName = sbn.packageName ?: return + + // Префильтр: интересуют только разрешённые приложения. + if (packageName !in store.getMonitoredPackages()) return + + val extras = notification.extras ?: return + val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + // big-text полнее обычного text — берём его при наличии. + val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT) + ?: extras.getCharSequence(Notification.EXTRA_TEXT)) + ?.toString() + ?.trim() + .orEmpty() + + if (body.isEmpty()) return + + store.enqueue(packageName, title, body, sbn.postTime) + LiveSink.tick() + } +} diff --git a/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationQueueStore.kt b/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationQueueStore.kt new file mode 100644 index 0000000..b94a096 --- /dev/null +++ b/android/app/src/main/kotlin/com/sanders/budget/new_budget/notifications/NotificationQueueStore.kt @@ -0,0 +1,83 @@ +package com.sanders.budget.new_budget.notifications + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** + * Персистентное хранилище входящих уведомлений на нативной стороне. + * + * Источник истины для ingestion: [NotificationIngestService] складывает сюда + * пойманные уведомления (даже когда Flutter-движок выключен), а Dart дренит + * очередь через MethodChannel `drainPending`. Дополнительно хранит allowlist + * мониторимых пакетов (синхронизируется из Dart), чтобы префильтровать на + * нативной стороне и не плодить мусор. + */ +class NotificationQueueStore(context: Context) { + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** Добавляет уведомление в очередь (с ограничением размера). */ + @Synchronized + fun enqueue(packageName: String, title: String?, body: String, postTimeMillis: Long) { + val array = readQueue() + val item = JSONObject().apply { + put(KEY_PACKAGE, packageName) + put(KEY_TITLE, title ?: JSONObject.NULL) + put(KEY_BODY, body) + put(KEY_RECEIVED_AT, postTimeMillis) + } + array.put(item) + // Держим только последние MAX_QUEUE элементов, чтобы не разрастаться. + val trimmed = if (array.length() > MAX_QUEUE) { + JSONArray().also { out -> + for (i in array.length() - MAX_QUEUE until array.length()) { + out.put(array.get(i)) + } + } + } else { + array + } + prefs.edit().putString(KEY_QUEUE, trimmed.toString()).apply() + } + + /** Возвращает и атомарно очищает очередь. */ + @Synchronized + fun drainAll(): JSONArray { + val array = readQueue() + prefs.edit().remove(KEY_QUEUE).commit() + return array + } + + @Synchronized + fun setMonitoredPackages(packages: Set) { + prefs.edit().putStringSet(KEY_MONITORED, packages).apply() + } + + @Synchronized + fun getMonitoredPackages(): Set = + prefs.getStringSet(KEY_MONITORED, emptySet()) ?: emptySet() + + private fun readQueue(): JSONArray { + val raw = prefs.getString(KEY_QUEUE, null) ?: return JSONArray() + return try { + JSONArray(raw) + } catch (e: Exception) { + JSONArray() + } + } + + companion object { + private const val PREFS_NAME = "notif_ingest" + private const val KEY_QUEUE = "queue" + private const val KEY_MONITORED = "monitored_packages" + private const val MAX_QUEUE = 500 + + const val KEY_PACKAGE = "packageName" + const val KEY_TITLE = "title" + const val KEY_BODY = "body" + const val KEY_RECEIVED_AT = "receivedAt" + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0f5e3d6 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + NewBudget — распознавание уведомлений + diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md new file mode 100644 index 0000000..19ab030 --- /dev/null +++ b/docs/TEST_PLAN.md @@ -0,0 +1,332 @@ +# План развития тестов NewBudget + +Документ — результат ревью существующих тестов (23 файла, ~4000 строк, июнь 2026). +Существующие тесты в хорошем состоянии: их **не трогаем** (кроме пары мелочей в Этапе 6). +План закрывает пробелы покрытия в порядке убывания ценности. + +## Сводка этапов + +| Этап | Что | Зачем | Объём (~тестов) | +|---|---|---|---| +| 1 | `month_summary` — агрегаты главного экрана | Не покрыта ключевая бизнес-логика; запланирован фикс переводов | 12–15 | +| 2 | `parseAmountToMinor` + `Money` | Денежный ввод не тестируется нигде | 15–20 | +| 3 | Миграции → drift `SchemaVerifier` | Текущий подход «отката» схемы растёт квадратично | 3 (переписать) | +| 4 | Негативные сценарии `ParsingPipeline` | Покрыт только happy-path; инфраструктура уже готова | 6–8 | +| 5 | Accounts / Categories / UserSeeder | Ноль тестов на целые фичи | 12–15 | +| 6 | Мелкие улучшения существующих тестов | Хрупкость и дубли | — | + +Этапы независимы — можно делать выборочно. Внутри этапа порядок кейсов = приоритет. + +## Конвенции (обязательны для всех новых тестов) + +Из `CLAUDE.md` и сложившейся практики: + +- **Без mocktail/mockito.** Фейки: `FakeXxxRepository implements XxxRepository` с + `noSuchMethod => throw UnimplementedError(...)` для неиспользуемых методов; + `FakeXxxController extends XxxController` с переопределённым `build()`. +- Unit: `ProviderContainer(overrides: [...])` + `addTearDown(container.dispose)`. + Widget: `ProviderScope(overrides: [...], child: MaterialApp(...))` + + `GoogleFonts.config.allowRuntimeFetching = false` в `setUpAll`. +- Реальная БД: `AppDatabase.forTesting(NativeDatabase.memory())`, сидим FK-цепочку + user → account → category перед транзакциями. +- `import 'package:drift/drift.dart' hide isNull, isNotNull;` при конфликте с matcher. +- Тексты в UI-тестах — через `AppLocalizations.delegate.load(...)`, не литералами + (см. `inbox_card_test.dart:139` как образец). +- Имена тестов — по-русски, описывают поведение, а не реализацию. + +--- + +## Этап 1. `month_summary` — агрегаты главного экрана + +**Файл кода:** `lib/src/features/home/presentation/month_summary.dart` +**Новый тест:** `test/features/home/month_summary_test.dart` + +Это самая важная дыра: чистая агрегатная логика (доход/расход/переводы, «все счета» +vs конкретный счёт), которую пользователь видит каждый день. В бэклоге висит +«Transfer transactions: fix balance aggregation» — тесты должны **запиннить текущее +поведение до фикса**, чтобы фикс делался осознанным изменением ассертов. + +### Инфраструктура + +Провайдер читает `transactionsStreamProvider(userId, from:, to:)` через +`.watch(...).value ?? []`. Переопределяем стрим: + +```dart +ProviderContainer(overrides: [ + transactionsStreamProvider(userId, from: monthStart, to: monthEnd) + .overrideWith((ref) => Stream.value(txs)), +]); +``` + +**Гочи:** +- `from`/`to` в override должны бит-в-бит совпадать с тем, что вычисляет провайдер: + `monthStart = DateTime(y, m)`, `monthEnd = DateTime(y, m + 1).subtract(Duration(milliseconds: 1))`. + Месяц фиксируем через `selectedMonthProvider` (см. ниже), иначе провайдер возьмёт + `DateTime.now()` и override не сматчится. +- `Stream.value` эмитит асинхронно — первый синхронный `read` увидит `AsyncLoading` + и вернёт пустой список. Перед ассертами: + `await container.read(transactionsStreamProvider(...).future);` +- Месяц: `container.read(selectedMonthProvider.notifier)` не имеет сеттера на произвольную + дату (только `previous`/`next`). Два варианта: (а) строить тестовые транзакции в + текущем месяце `DateTime.now()`, как делает `tx_row_habit_test`; (б) добавить в + `SelectedMonth` метод `select(DateTime)` — он пригодится и для UI. Предпочтителен (б), + но это изменение кода — согласовать. По умолчанию — (а). +- Счёт: `container.read(selectedAccountProvider.notifier).select('a-1')`; + «Все счета» = дефолт (`kAllAccountsId == ''`). + +### Кейсы `monthSummaryProvider` + +Фикстура: счета `a1`, `a2`; категории `c1`, `c2`. Транзакции (минорные единицы): + +| id | тип | счёт | категория | сумма | примечание | +|---|---|---|---|---|---| +| t1 | expense | a1 | c1 | 1000 | | +| t2 | expense | a1 | c2 | 2000 | | +| t3 | expense | a2 | c1 | 400 | | +| t4 | income | a1 | — | 5000 | | +| t5 | transfer a1→a2 | a1 | — | 700 | `transferToAccountId: a2` | +| t6 | expense | a1 | null | 300 | без категории | + +1. **«Все счета»: income/expense/balance.** income=5000, expense=1000+2000+400+300=3700, + balance=1300. Переводы (t5) не входят ни в доход, ни в расход. +2. **«Все счета»: spendByCategory.** `{c1: 1400, c2: 2000}`; t6 (без категории) в карту + не попадает, но в `expensesMinor` входит. Отдельный ассерт на это расхождение — + оно неочевидно и влияет на донат-чарт. +3. **«Все счета»: transactionsCount = 6** (переводы считаются в количестве). +4. **Счёт a1:** income=5000, expense=1000+2000+300+700(перевод-исход)=4000, balance=1000. +5. **Счёт a2:** income=700 (входящий перевод t5), expense=400, balance=300. + Это пиннит текущее поведение «перевод НА счёт = доход» — при будущем фиксе + агрегации ассерт меняется осознанно. +6. **Счёт a2: transactionsCount = 2** (t3 + входящий t5). +7. **Пустой месяц** (нет транзакций) → все нули, пустая карта. +8. **spendByCategory не зависит от категория-фильтра** — фильтр категорий влияет только + на `filteredTransactions`, не на summary (задокументированное поведение). + +### Кейсы `filteredTransactionsProvider` + +9. Без фильтров → все 6. +10. Счёт a2 → t3 + входящий перевод t5 (для конкретного счёта переводы НА него включаются). +11. Категория-фильтр `{c1}` → t1, t3 (t5/t4/t6 отпадают: их `categoryId` не в множестве). +12. Комбинация счёт a1 + категория `{c2}` → только t2. + +### Кейсы `categoriesBySpend` (чистая функция, без контейнера) + +13. Сортировка по убыванию суммы. +14. Категории с нулевой/отсутствующей тратой не попадают в результат. +15. Категория есть в spend, но нет в списке categories (удалена/архив) → не попадает, + без исключения. + +--- + +## Этап 2. Деньги: `parseAmountToMinor` + `Money` + +### 2a. `parseAmountToMinor` + +**Файл кода:** `lib/src/features/transactions/presentation/widgets/amount_input.dart:12` +**Новый тест:** `test/features/transactions/presentation/amount_input_test.dart` + +Чистая функция, тестируется без виджетов. Кейсы: + +| Ввод | Ожидание | Что проверяет | +|---|---|---| +| `'1234'` | 123400 | целое → ×100 | +| `'1234.56'` | 123456 | точка-разделитель | +| `'1234,56'` | 123456 | запятая-разделитель | +| `'1 234,56'` | 123456 | пробелы внутри числа | +| `'.5'` / `',5'` | 50 | пустая целая часть | +| `'12.5'` | 1250 | один знак дробной — `padRight` | +| `'12.567'` | 1256 | >2 знаков — усечение (НЕ округление: 12.567 → 1256). Пиннит текущее поведение | +| `''` / `' '` | 0 | пусто | +| `'1.2.3'` | 0 | два разделителя | +| `'abc'`, `'12a'` | 0 | мусор | +| `'0'` | 0 | ноль (форма должна отклонить как «Enter an amount») | + +Плюс 1–2 widget-теста на сам `AmountInput`: ввод `'1 234,56'` через +`tester.enterText` → `onChanged` получил 123456; `inputFormatters` не пропускают буквы. +(Это закроет разрыв: текущие тесты формы задают сумму через `notifier.setAmount`, +минуя парсинг.) + +### 2b. `Money` + +**Файл кода:** `lib/src/core/money/money.dart` +**Новый тест:** `test/core/money/money_test.dart` + +1. `fromAmount(12.34, 'RUB')` → 1234; `fromAmount(0.1 + 0.2, ...)` → 30 (round спасает от FP). +2. `fromAmount(100, 'JPY')` → 100 (нулевые decimals); `amount` для JPY не делит на 100. +3. Round-trip: `fromAmount(x).amount == x` для типичных значений. +4. Операторы `+`/`-`/`*` (включая `* 0.5` с округлением `.round()` — банковское не используется, пиннить как есть). +5. `==`/`hashCode`: равенство по minorUnits+currency; разные валюты не равны. +6. `toString`: `'RUB 12.34'`, `'JPY 100'`. +7. (Опционально) assert при `+` разных валют — проверять через `throwsA(isA())`, + работает только в debug; пометить комментарием. + +--- + +## Этап 3. Миграции БД → drift `SchemaVerifier` + +**Сейчас:** `migration_v6/v7/v8_test.dart` поднимают актуальную схему и «откатывают» её +вручную (`ALTER TABLE ... DROP COLUMN`, `PRAGMA user_version`). Проблемы: + +- Квадратичный рост: v6-тест уже вынужден удалять артефакты v7 **и** v8; каждая новая + версия схемы требует править все старые тесты. +- Тестируется «схема, похожая на старую», а не реальная старая: отличия в дефолтах, + индексах, constraint'ах не ловятся. + +**Целевое состояние** — штатный механизм drift: + +1. Снять снапшоты схем: `dart run drift_dev schema dump lib/src/core/database/app_database.dart drift_schemas/` + (создаст `drift_schemas/drift_schema_vN.json`; коммитятся в репозиторий). + Снапшоты старых версий генерируются один раз из git-истории: checkout коммита с + `schemaVersion = N` → dump → вернуть HEAD. Версии для checkout искать по + `git log -S 'schemaVersion' -- lib/src/core/database/app_database.dart`. +2. Сгенерировать тестовую обвязку: + `dart run drift_dev schema generate drift_schemas/ test/core/database/generated/`. +3. Переписать три теста на `SchemaVerifier` (`package:drift_dev/api/migrations.dart`): + +```dart +final verifier = SchemaVerifier(GeneratedHelper()); +final connection = await verifier.startAt(5); +final db = AppDatabase(connection); +await verifier.migrateAndValidate(db, 8); +``` + +4. Сохранить смысловые round-trip-проверки из текущих тестов (вставка строки с + `obligation`/`txType` после миграции) — `migrateAndValidate` проверяет структуру, + но не конвертеры enum'ов. +5. Добавить в `CLAUDE.md` правило: при бампе `schemaVersion` — новый dump + тест + `startAt(N-1) → migrateAndValidate(N)`. + +**Definition of done:** старые три файла удалены, новые тесты зелёные, процедура +снапшота задокументирована. Если шаг 1 (восстановление старых схем из git) окажется +дорогим — допустимый компромисс: зафиксировать текущую v8 как первый снапшот и +переводить на SchemaVerifier только будущие миграции (v8 → v9+), оставив старые +тесты как есть до их естественного устаревания. + +--- + +## Этап 4. Негативные сценарии `ParsingPipeline` / воркера + +**Новый тест:** `test/features/notification_parsing/application/parsing_pipeline_negative_test.dart` +**Переиспользовать:** `_fakeAiParser` (MockClient), `_seed`, `_activateWorker`, +`_waitTerminal` из `parsing_worker_allowlist_test.dart` — **вынести их в +`test/features/notification_parsing/support.dart`**, чтобы не копировать. + +Кейсы (все офлайн, через MockClient): + +1. **AI вернул битый JSON** (MockClient отдаёт `'not a json'` в `content`) → + терминальный статус `failed`, `lastParseError` непустой, транзакций нет. + (Проверить заодно `ai_tolerant_json.dart`: что именно он прощает — markdown-обёртку + ```` ```json ```` — а что нет.) +2. **AI вернул HTTP 500** → `failed` (или retry-поведение, если оно есть — пиннить фактическое). +3. **`autoApplyEnabled = false`** при полностью проходящем чек-листе (правило + сумма + в теле + дефолтный счёт) → `inbox`, транзакций нет. Сейчас toggle проверен только + на уровне `decide()`, но не сквозь pipeline. +4. **Правило `ignore`** на мерчанта → статус `ignored`, AI **не вызывался** (`onCall`-флаг). +5. **Дедупликация:** два `insertIncoming` с одинаковыми `packageName`+`body` → + второе сообщение не порождает второй обработки/транзакции (проверить фактический + контракт `insertIncoming`: возвращает существующее? вставляет со статусом `duplicate`? + — пиннить реальное поведение). +6. **Дневной лимит токенов исчерпан** (`setAiDailyTokenLimit(10)` + `addTokenUsage(20)`) + → AI не вызывается, сообщение уходит в ожидаемый статус (по коду — regex-only путь; + уточнить по `parsing_pipeline.dart` и зафиксировать). + +### 4b. `ParsingSettingsController` (unit, in-memory БД) + +**Новый тест:** `test/features/notification_parsing/application/parsing_settings_controller_test.dart` + +1. Дефолты: `enabled=true`, `autoApply=true`, `aiConsent=false`, модель = `kDefaultAiModel`. +2. `addTokenUsage` аккумулирует; `tokensUsedToday` в state обновляется. +3. `isDailyLimitReached`: false без лимита; true при `used >= limit`. +4. Смена дня: ключ счётчика содержит дату (`ai_token_usage_YYYY-MM-DD`). + `DateTime.now()` не инжектится — честно протестировать «вчерашний счётчик не + читается» можно, записав преференс с вчерашним ключом напрямую через + `settingsDao.setPreference('ai_token_usage_<вчера>', '999')` и проверив, что + `build()` вернул 0. (Опционально: рефакторинг `_tokenUsageKey` на инжектируемые + часы — отдельным решением.) +5. Персистентность: значения переживают пересоздание контейнера над той же БД. + +--- + +## Этап 5. Accounts / Categories / UserSeeder + +### 5a. `AccountRepositoryImpl` (in-memory БД) + +**Новый тест:** `test/features/accounts/repository/account_repository_test.dart` + +1. CRUD round-trip: create → findById с полями (currency, iconCode, colorValue, initialBalance). +2. **`setDefault`: инвариант единственного дефолта.** Два счёта, `setDefault(a1)`, + затем `setDefault(a2)` → `isDefault` только у a2. Это опора account resolver'а + (`globalDefault`, score 40) и gate-проверки `accountTrusted` — самый ценный кейс этапа. +3. `setDefault(null, userId)` снимает дефолт со всех. +4. `archive`: архивный счёт исчезает из основного watch-потока (или помечается — по + фактическому контракту DAO), но находится по id. +5. Изоляция пользователей: счета другого `userId` не видны. + +### 5b. `CategoryRepositoryImpl` + +**Новый тест:** `test/features/categories/repository/category_repository_test.dart` + +1. CRUD round-trip, включая `parentId` (подкатегория) и его обнуление. +2. Фильтрация по `type` (expense/income) — то, на что завязана форма транзакции. +3. `archive` + изоляция пользователей (аналогично счетам). + +### 5c. `UserSeeder` (реальный, in-memory БД) + +**Новый тест:** `test/features/user/user_seeder_test.dart` + +Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом +от него зависит первый запуск приложения. + +1. `seedForNewUser(userId)` → созданы дефолтные счета и категории (количества > 0; + точные наборы не пиннить — они будут меняться), все с правильным `userId`. +2. Один счёт помечен `isDefault` (если это контракт сидера — проверить по коду). +3. Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела). + Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md) + достаточно было удалить один блок ассертов. +4. Повторный вызов для того же пользователя: пиннить фактическое поведение + (дубли? идемпотентность?) — это поведение при «втором онбординге». + +### 5d. Контроллеры accounts/categories — только если в них есть логика + +Если `AccountsController`/`CategoriesController` лишь проксируют репозиторий — +отдельные тесты не нужны (паттерн уже покрыт `users_controller_test`). Тестировать +только если есть валидация/оркестрация (проверить по коду перед написанием). + +--- + +## Этап 6. Мелкие улучшения существующих тестов + +1. **`onboarding_screen_test.dart`:** заменить литералы `'Welcome'`, `'Continue'`, + `'Your name'` на строки из `AppLocalizations.delegate.load(const Locale('en'))` — + по образцу `inbox_card_test.dart:139`. Иначе правка ARB валит тесты с невнятной + ошибкой. +2. **Общие фейки:** `FakeTransactionRepository` существует в трёх вариантах + (transactions_controller, inbox_controller, форма). Вынести один полный в + `test/support/fakes.dart`, остальные удалить. Туда же — `_UnusedXxxRepo`-заглушки. +3. **`widget_test.dart`:** добавить ассерт, что без активного пользователя показан + `OnboardingScreen` (проверка redirect-логики роутера почти бесплатно). +4. **`usersStreamProvider`-тест** (`users_controller_test.dart:332`): сейчас фейковый + `watchAll()` возвращает одноразовый `Stream.value`, и реактивность не проверяется. + Заменить в фейке на `StreamController.broadcast` с ре-эмитом после `create` — + тогда тест начнёт проверять то, что декларирует. +5. **`test/features/notification_parsing/support.dart`** — см. Этап 4 (общая обвязка + воркер-тестов). + +--- + +## Команды + +```bash +flutter test # весь оффлайн-набор (должен быть зелёным всегда) +flutter test test/features/home/month_summary_test.dart # один файл +flutter test --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-... # сетевые +dart run build_runner build --delete-conflicting-outputs # если меняли @riverpod-код +``` + +## Definition of done (на каждый этап) + +- `flutter analyze` чистый, `flutter test` зелёный без сети и без API-ключей. +- Новые тесты следуют конвенциям из раздела выше (фейки без mockito, l10n без литералов). +- Тесты, пиннящие «спорное» текущее поведение (переводы в month_summary, усечение + в parseAmountToMinor, повторный сид), помечены комментарием `// Пиннит текущее + поведение: ...` — чтобы при осознанном изменении поведения их меняли, а не «чинили». diff --git a/docs/notification_parsing_status.md b/docs/notification_parsing_status.md index 3a123e5..2115d1f 100644 --- a/docs/notification_parsing_status.md +++ b/docs/notification_parsing_status.md @@ -41,7 +41,7 @@ AI (Phase 2), переводы (Phase 3) и калибровка (Phase 4) — - ✅ [rule_suggester.dart](../lib/src/features/notification_parsing/data/parser/rule_suggester.dart) + [merchant_normalizer.dart](../lib/src/features/notification_parsing/data/parser/merchant_normalizer.dart) — наблюдение кандидатов, предложение «merchant → category». - ✅ [confidence_scorer.dart](../lib/src/features/notification_parsing/data/parser/confidence_scorer.dart) — 5 per-field оценок + sanity-checks (capping, `category ≤ merchant`, `looksLikeNonTransaction`). - ✅ [decision_gate.dart](../lib/src/features/notification_parsing/data/parser/decision_gate.dart) — gate §8.7: правило + sanity + `min(amount,account,type) ≥ строгость` → auto-apply, иначе Inbox; крупная сумма → всегда Inbox. -- ✅ [dedup.dart](../lib/src/features/notification_parsing/data/parser/dedup.dart) — `dedupHash(packageName, body)` (FNV-1a, без времени). +- ✅ [dedup.dart](../lib/src/features/notification_parsing/data/parser/dedup.dart) — `dedupHash(packageName, body)` (FNV-1a, без времени) + окно `kDedupWindow` (±3 мин по `receivedAt`) при вставке: тот же текст вне окна — новое сообщение (§15). - ✅ [draft_codec.dart](../lib/src/features/notification_parsing/data/parser/draft_codec.dart) — сериализация draft+предложения в `raw_messages.draftJson`. - ✅ [parsing_worker.dart](../lib/src/features/notification_parsing/application/parsing_worker.dart) — Riverpod-stream воркер над `pending`, идемпотентный; non-transaction → `ignored`, иначе Inbox. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f6f9ad2..1f3116f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -206,16 +206,26 @@ "parsingSettingsTitle": "Notification parsing", "parsingSettingsSubtitle": "Settings", "parsingEnableLabel": "Recognize notifications", - "parsingStrictnessLabel": "Auto-add strictness", - "parsingStrictnessSoft": "Soft", - "parsingStrictnessNormal": "Normal", - "parsingStrictnessStrict": "Strict", - "parsingStrictnessHint": "The threshold applies to amount, account and type — not to the merchant.", + "parsingAutoApplyLabel": "Auto-add transactions", + "parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.", + "inboxWhyNotAuto": "Why not automatic: {reasons}", + "@inboxWhyNotAuto": { "placeholders": { "reasons": { "type": "String" } } }, + "gateCheckRuleMatched": "no rule for this merchant", + "gateCheckAmountVerifiedInBody": "amount not found in the message text", + "gateCheckCurrencyKnown": "currency not recognized", + "gateCheckTypeMatchesRule": "transaction type differs from the rule", + "gateCheckAccountResolved": "account not resolved", + "gateCheckAccountTrusted": "ambiguous account", + "gateCheckAmountUnderCap": "amount is too large", "parsingRulesTile": "Parsing rules", "parsingRulesCreatedCount": "Rules created: {count}", "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, "parsingAiSectionTitle": "AI (OpenRouter)", "parsingAiComingSoon": "AI recognition is coming in a future version.", + "parsingNotifAccessTitle": "Notification access", + "parsingNotifAccessGranted": "Granted — notifications are being read", + "parsingNotifAccessDenied": "Not granted — tap to open settings", + "parsingNotifAccessOpenSettings": "Open settings", "parsingDebugSectionTitle": "Debug", "parsingDebugInjectTile": "Inject a test notification", @@ -249,6 +259,7 @@ "parsingDetailAttempt": "Attempt {count}/{max}", "@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } }, "parsingDetailSource": "Source", + "parsingDetailFailedChecks": "Why not automatic", "parsingDetailAmountScore": "Amount", "parsingDetailAccountScore": "Account", "parsingDetailTypeScore": "Type", @@ -394,6 +405,11 @@ "sourceAppsAddManual": "Add manually", "sourceAppsPackageLabel": "Package name", "sourceAppsNameLabel": "Display name (optional)", + "sourceAppsPickFromInstalled": "Choose from installed apps", + "sourceAppsPickerTitle": "Installed apps", + "sourceAppsSearchHint": "Search", + "sourceAppsPickerEmpty": "No apps found", + "sourceAppsAlreadyAdded": "Already added", "appBindingsTitle": "Account bindings", "appBindingsEmpty": "No bindings yet. Add one to map a card to an account.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3a9413b..b9d62fb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -908,35 +908,65 @@ abstract class AppLocalizations { /// **'Распознавать уведомления'** String get parsingEnableLabel; - /// No description provided for @parsingStrictnessLabel. + /// No description provided for @parsingAutoApplyLabel. /// /// In ru, this message translates to: - /// **'Скорость авто-добавления'** - String get parsingStrictnessLabel; + /// **'Авто-добавление транзакций'** + String get parsingAutoApplyLabel; - /// No description provided for @parsingStrictnessSoft. + /// No description provided for @parsingAutoApplyHint. /// /// In ru, this message translates to: - /// **'Мягко'** - String get parsingStrictnessSoft; + /// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'** + String get parsingAutoApplyHint; - /// No description provided for @parsingStrictnessNormal. + /// No description provided for @inboxWhyNotAuto. /// /// In ru, this message translates to: - /// **'Нормально'** - String get parsingStrictnessNormal; + /// **'Почему не автоматически: {reasons}'** + String inboxWhyNotAuto(String reasons); - /// No description provided for @parsingStrictnessStrict. + /// No description provided for @gateCheckRuleMatched. /// /// In ru, this message translates to: - /// **'Строго'** - String get parsingStrictnessStrict; + /// **'нет правила для мерчанта'** + String get gateCheckRuleMatched; - /// No description provided for @parsingStrictnessHint. + /// No description provided for @gateCheckAmountVerifiedInBody. /// /// In ru, this message translates to: - /// **'Порог применяется к сумме, счёту и типу — не к мерчанту.'** - String get parsingStrictnessHint; + /// **'сумма не найдена в тексте сообщения'** + String get gateCheckAmountVerifiedInBody; + + /// No description provided for @gateCheckCurrencyKnown. + /// + /// In ru, this message translates to: + /// **'валюта не распознана'** + String get gateCheckCurrencyKnown; + + /// No description provided for @gateCheckTypeMatchesRule. + /// + /// In ru, this message translates to: + /// **'тип операции отличается от правила'** + String get gateCheckTypeMatchesRule; + + /// No description provided for @gateCheckAccountResolved. + /// + /// In ru, this message translates to: + /// **'счёт не определён'** + String get gateCheckAccountResolved; + + /// No description provided for @gateCheckAccountTrusted. + /// + /// In ru, this message translates to: + /// **'счёт неоднозначен'** + String get gateCheckAccountTrusted; + + /// No description provided for @gateCheckAmountUnderCap. + /// + /// In ru, this message translates to: + /// **'слишком крупная сумма'** + String get gateCheckAmountUnderCap; /// No description provided for @parsingRulesTile. /// @@ -962,6 +992,30 @@ abstract class AppLocalizations { /// **'ИИ-распознавание появится в следующей версии.'** String get parsingAiComingSoon; + /// No description provided for @parsingNotifAccessTitle. + /// + /// In ru, this message translates to: + /// **'Доступ к уведомлениям'** + String get parsingNotifAccessTitle; + + /// No description provided for @parsingNotifAccessGranted. + /// + /// In ru, this message translates to: + /// **'Выдан — уведомления читаются'** + String get parsingNotifAccessGranted; + + /// No description provided for @parsingNotifAccessDenied. + /// + /// In ru, this message translates to: + /// **'Не выдан — нажмите, чтобы открыть настройки'** + String get parsingNotifAccessDenied; + + /// No description provided for @parsingNotifAccessOpenSettings. + /// + /// In ru, this message translates to: + /// **'Открыть настройки'** + String get parsingNotifAccessOpenSettings; + /// No description provided for @parsingDebugSectionTitle. /// /// In ru, this message translates to: @@ -1136,6 +1190,12 @@ abstract class AppLocalizations { /// **'Источник'** String get parsingDetailSource; + /// No description provided for @parsingDetailFailedChecks. + /// + /// In ru, this message translates to: + /// **'Почему не автоматически'** + String get parsingDetailFailedChecks; + /// No description provided for @parsingDetailAmountScore. /// /// In ru, this message translates to: @@ -1820,6 +1880,36 @@ abstract class AppLocalizations { /// **'Отображаемое имя (необязательно)'** String get sourceAppsNameLabel; + /// No description provided for @sourceAppsPickFromInstalled. + /// + /// In ru, this message translates to: + /// **'Выбрать из установленных'** + String get sourceAppsPickFromInstalled; + + /// No description provided for @sourceAppsPickerTitle. + /// + /// In ru, this message translates to: + /// **'Установленные приложения'** + String get sourceAppsPickerTitle; + + /// No description provided for @sourceAppsSearchHint. + /// + /// In ru, this message translates to: + /// **'Поиск'** + String get sourceAppsSearchHint; + + /// No description provided for @sourceAppsPickerEmpty. + /// + /// In ru, this message translates to: + /// **'Приложения не найдены'** + String get sourceAppsPickerEmpty; + + /// No description provided for @sourceAppsAlreadyAdded. + /// + /// In ru, this message translates to: + /// **'Уже добавлено'** + String get sourceAppsAlreadyAdded; + /// No description provided for @appBindingsTitle. /// /// In ru, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c3a9190..7424c55 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -469,20 +469,39 @@ class AppLocalizationsEn extends AppLocalizations { String get parsingEnableLabel => 'Recognize notifications'; @override - String get parsingStrictnessLabel => 'Auto-add strictness'; + String get parsingAutoApplyLabel => 'Auto-add transactions'; @override - String get parsingStrictnessSoft => 'Soft'; + String get parsingAutoApplyHint => + 'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.'; @override - String get parsingStrictnessNormal => 'Normal'; + String inboxWhyNotAuto(String reasons) { + return 'Why not automatic: $reasons'; + } @override - String get parsingStrictnessStrict => 'Strict'; + String get gateCheckRuleMatched => 'no rule for this merchant'; @override - String get parsingStrictnessHint => - 'The threshold applies to amount, account and type — not to the merchant.'; + String get gateCheckAmountVerifiedInBody => + 'amount not found in the message text'; + + @override + String get gateCheckCurrencyKnown => 'currency not recognized'; + + @override + String get gateCheckTypeMatchesRule => + 'transaction type differs from the rule'; + + @override + String get gateCheckAccountResolved => 'account not resolved'; + + @override + String get gateCheckAccountTrusted => 'ambiguous account'; + + @override + String get gateCheckAmountUnderCap => 'amount is too large'; @override String get parsingRulesTile => 'Parsing rules'; @@ -499,6 +518,19 @@ class AppLocalizationsEn extends AppLocalizations { String get parsingAiComingSoon => 'AI recognition is coming in a future version.'; + @override + String get parsingNotifAccessTitle => 'Notification access'; + + @override + String get parsingNotifAccessGranted => + 'Granted — notifications are being read'; + + @override + String get parsingNotifAccessDenied => 'Not granted — tap to open settings'; + + @override + String get parsingNotifAccessOpenSettings => 'Open settings'; + @override String get parsingDebugSectionTitle => 'Debug'; @@ -589,6 +621,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get parsingDetailSource => 'Source'; + @override + String get parsingDetailFailedChecks => 'Why not automatic'; + @override String get parsingDetailAmountScore => 'Amount'; @@ -952,6 +987,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sourceAppsNameLabel => 'Display name (optional)'; + @override + String get sourceAppsPickFromInstalled => 'Choose from installed apps'; + + @override + String get sourceAppsPickerTitle => 'Installed apps'; + + @override + String get sourceAppsSearchHint => 'Search'; + + @override + String get sourceAppsPickerEmpty => 'No apps found'; + + @override + String get sourceAppsAlreadyAdded => 'Already added'; + @override String get appBindingsTitle => 'Account bindings'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3b2d31d..3187c82 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -481,20 +481,38 @@ class AppLocalizationsRu extends AppLocalizations { String get parsingEnableLabel => 'Распознавать уведомления'; @override - String get parsingStrictnessLabel => 'Скорость авто-добавления'; + String get parsingAutoApplyLabel => 'Авто-добавление транзакций'; @override - String get parsingStrictnessSoft => 'Мягко'; + String get parsingAutoApplyHint => + 'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'; @override - String get parsingStrictnessNormal => 'Нормально'; + String inboxWhyNotAuto(String reasons) { + return 'Почему не автоматически: $reasons'; + } @override - String get parsingStrictnessStrict => 'Строго'; + String get gateCheckRuleMatched => 'нет правила для мерчанта'; @override - String get parsingStrictnessHint => - 'Порог применяется к сумме, счёту и типу — не к мерчанту.'; + String get gateCheckAmountVerifiedInBody => + 'сумма не найдена в тексте сообщения'; + + @override + String get gateCheckCurrencyKnown => 'валюта не распознана'; + + @override + String get gateCheckTypeMatchesRule => 'тип операции отличается от правила'; + + @override + String get gateCheckAccountResolved => 'счёт не определён'; + + @override + String get gateCheckAccountTrusted => 'счёт неоднозначен'; + + @override + String get gateCheckAmountUnderCap => 'слишком крупная сумма'; @override String get parsingRulesTile => 'Правила парсинга'; @@ -511,6 +529,19 @@ class AppLocalizationsRu extends AppLocalizations { String get parsingAiComingSoon => 'ИИ-распознавание появится в следующей версии.'; + @override + String get parsingNotifAccessTitle => 'Доступ к уведомлениям'; + + @override + String get parsingNotifAccessGranted => 'Выдан — уведомления читаются'; + + @override + String get parsingNotifAccessDenied => + 'Не выдан — нажмите, чтобы открыть настройки'; + + @override + String get parsingNotifAccessOpenSettings => 'Открыть настройки'; + @override String get parsingDebugSectionTitle => 'Отладка'; @@ -601,6 +632,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get parsingDetailSource => 'Источник'; + @override + String get parsingDetailFailedChecks => 'Почему не автоматически'; + @override String get parsingDetailAmountScore => 'Сумма'; @@ -965,6 +999,21 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sourceAppsNameLabel => 'Отображаемое имя (необязательно)'; + @override + String get sourceAppsPickFromInstalled => 'Выбрать из установленных'; + + @override + String get sourceAppsPickerTitle => 'Установленные приложения'; + + @override + String get sourceAppsSearchHint => 'Поиск'; + + @override + String get sourceAppsPickerEmpty => 'Приложения не найдены'; + + @override + String get sourceAppsAlreadyAdded => 'Уже добавлено'; + @override String get appBindingsTitle => 'Привязки счетов'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9409701..558144f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -206,16 +206,26 @@ "parsingSettingsTitle": "Парсинг уведомлений", "parsingSettingsSubtitle": "Настройки", "parsingEnableLabel": "Распознавать уведомления", - "parsingStrictnessLabel": "Скорость авто-добавления", - "parsingStrictnessSoft": "Мягко", - "parsingStrictnessNormal": "Нормально", - "parsingStrictnessStrict": "Строго", - "parsingStrictnessHint": "Порог применяется к сумме, счёту и типу — не к мерчанту.", + "parsingAutoApplyLabel": "Авто-добавление транзакций", + "parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.", + "inboxWhyNotAuto": "Почему не автоматически: {reasons}", + "@inboxWhyNotAuto": { "placeholders": { "reasons": { "type": "String" } } }, + "gateCheckRuleMatched": "нет правила для мерчанта", + "gateCheckAmountVerifiedInBody": "сумма не найдена в тексте сообщения", + "gateCheckCurrencyKnown": "валюта не распознана", + "gateCheckTypeMatchesRule": "тип операции отличается от правила", + "gateCheckAccountResolved": "счёт не определён", + "gateCheckAccountTrusted": "счёт неоднозначен", + "gateCheckAmountUnderCap": "слишком крупная сумма", "parsingRulesTile": "Правила парсинга", "parsingRulesCreatedCount": "Правил создано: {count}", "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, "parsingAiSectionTitle": "AI (OpenRouter)", "parsingAiComingSoon": "ИИ-распознавание появится в следующей версии.", + "parsingNotifAccessTitle": "Доступ к уведомлениям", + "parsingNotifAccessGranted": "Выдан — уведомления читаются", + "parsingNotifAccessDenied": "Не выдан — нажмите, чтобы открыть настройки", + "parsingNotifAccessOpenSettings": "Открыть настройки", "parsingDebugSectionTitle": "Отладка", "parsingDebugInjectTile": "Вставить тестовое уведомление", @@ -249,6 +259,7 @@ "parsingDetailAttempt": "Попытка {count}/{max}", "@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } }, "parsingDetailSource": "Источник", + "parsingDetailFailedChecks": "Почему не автоматически", "parsingDetailAmountScore": "Сумма", "parsingDetailAccountScore": "Счёт", "parsingDetailTypeScore": "Тип", @@ -394,6 +405,11 @@ "sourceAppsAddManual": "Добавить вручную", "sourceAppsPackageLabel": "Имя пакета", "sourceAppsNameLabel": "Отображаемое имя (необязательно)", + "sourceAppsPickFromInstalled": "Выбрать из установленных", + "sourceAppsPickerTitle": "Установленные приложения", + "sourceAppsSearchHint": "Поиск", + "sourceAppsPickerEmpty": "Приложения не найдены", + "sourceAppsAlreadyAdded": "Уже добавлено", "appBindingsTitle": "Привязки счетов", "appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.", diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index 12a8776..d8cedba 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -68,7 +68,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration => MigrationStrategy( @@ -126,6 +126,11 @@ class AppDatabase extends _$AppDatabase { accountBindingsTable, accountBindingsTable.isDefault); await m.createTable(sourceAppsTable); } + if (from < 8) { + // v7 → v8: тип операции фиксируется в правиле (для gate-проверки + // typeMatchesRule). + await m.addColumn(parseRulesTable, parseRulesTable.txType); + } }, ); diff --git a/lib/src/features/notification_parsing/application/inbox_controller.dart b/lib/src/features/notification_parsing/application/inbox_controller.dart index cd5e183..2524077 100644 --- a/lib/src/features/notification_parsing/application/inbox_controller.dart +++ b/lib/src/features/notification_parsing/application/inbox_controller.dart @@ -63,6 +63,7 @@ class InboxController extends _$InboxController { kind: ParseRuleKind.merchantToCategory, matchMode: matchMode, pattern: pattern, + txType: draft.type, merchantCanonical: merchantCanonical, categoryId: categoryId, ); diff --git a/lib/src/features/notification_parsing/application/notification_access_controller.dart b/lib/src/features/notification_parsing/application/notification_access_controller.dart new file mode 100644 index 0000000..abbcc51 --- /dev/null +++ b/lib/src/features/notification_parsing/application/notification_access_controller.dart @@ -0,0 +1,24 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'notification_parsing_providers.dart'; + +part 'notification_access_controller.g.dart'; + +/// Выдан ли доступ «Чтение уведомлений» (Android). На прочих платформах — false. +@riverpod +Future notificationAccessStatus(Ref ref) => + ref.watch(notificationListenerChannelProvider).isPermissionGranted(); + +/// Действия над разрешением: открыть системные настройки и обновить статус. +@riverpod +class NotificationAccessController extends _$NotificationAccessController { + @override + void build() {} + + /// Открывает системный экран «Доступ к уведомлениям». + Future openSettings() => + ref.read(notificationListenerChannelProvider).openSettings(); + + /// Перечитывает статус разрешения (например, после возврата из настроек). + void refresh() => ref.invalidate(notificationAccessStatusProvider); +} diff --git a/lib/src/features/notification_parsing/application/notification_ingest_worker.dart b/lib/src/features/notification_parsing/application/notification_ingest_worker.dart new file mode 100644 index 0000000..8f738ca --- /dev/null +++ b/lib/src/features/notification_parsing/application/notification_ingest_worker.dart @@ -0,0 +1,88 @@ +import 'package:flutter/widgets.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'notification_parsing_providers.dart'; + +part 'notification_ingest_worker.g.dart'; + +/// Ingestion-драйвер нативных уведомлений (Android). +/// +/// Мост между нативным [NotificationListenerChannel] и Dart-пайплайном: +/// синхронизирует allowlist в native, дренит нативную очередь и кладёт каждое +/// уведомление в `raw_messages` через `insertIncoming` (идемпотентно по +/// dedupHash). Дальше его подхватывает [ParsingWorker] — этот воркер за разбор +/// не отвечает, только за «как сообщения попадают в очередь». +/// +/// Дренит при: старте, тике EventChannel (новое уведомление пока приложение +/// открыто) и возврате в foreground (`AppLifecycleState.resumed`) — на случай +/// уведомлений, пойманных пока движок был выключен. +/// +/// Провайдер `keepAlive` — активируется чтением из `AppScaffold` (только Android). +@Riverpod(keepAlive: true) +class NotificationIngestWorker extends _$NotificationIngestWorker { + bool _draining = false; + bool _rerun = false; + + @override + void build(String userId) { + // Синхронизируем allowlist мониторимых пакетов в native. + final pkgSub = ref.listen( + enabledSourcePackagesProvider(userId), + (_, next) { + final set = next.value; + if (set != null) { + ref + .read(notificationListenerChannelProvider) + .setMonitoredPackages(set); + } + }, + fireImmediately: true, + ); + ref.onDispose(pkgSub.close); + + // Тик из EventChannel «появилось новое уведомление» → дрейн. + final tickSub = ref + .read(notificationListenerChannelProvider) + .ticks() + .listen((_) => _drain(userId)); + ref.onDispose(tickSub.cancel); + + // Пере-дрейн при возврате приложения в foreground. + final lifecycle = AppLifecycleListener( + onResume: () => _drain(userId), + ); + ref.onDispose(lifecycle.dispose); + + // Стартовый дрейн: забрать всё, что пришло пока приложение было закрыто. + _drain(userId); + } + + Future _drain(String userId) async { + // Если дрейн уже идёт — отметим, что нужен повторный проход: уведомление, + // пришедшее после drainPending, иначе дождётся лишь следующего тика. + if (_draining) { + _rerun = true; + return; + } + _draining = true; + try { + final channel = ref.read(notificationListenerChannelProvider); + final repo = ref.read(rawMessagesRepositoryProvider); + do { + _rerun = false; + final items = await channel.drainPending(); + for (final item in items) { + await repo.insertIncoming( + userId: userId, + packageName: item.packageName, + title: item.title, + body: item.body, + receivedAt: item.receivedAt, + ); + } + } while (_rerun); + } finally { + _draining = false; + } + } +} diff --git a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart index 386e90b..efb4e2f 100644 --- a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart +++ b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart @@ -1,6 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/providers/database_provider.dart'; +import '../data/native/notification_listener_channel.dart'; import '../data/repositories/account_bindings_repository_impl.dart'; import '../data/repositories/parse_rules_repository_impl.dart'; import '../data/repositories/raw_messages_repository_impl.dart'; @@ -38,7 +39,22 @@ AccountBindingsRepository accountBindingsRepository(Ref ref) => SourceAppsRepository sourceAppsRepository(Ref ref) => SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao); -/// Множество включённых packageName пользователя — вход allowlist-фильтра. +/// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое +/// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не +/// протухал до перезапуска. Подписка на стрим требует прямого слушателя +/// (`ref.listen`) — `read(.future)` без него не сработает; pipeline поэтому +/// берёт снапшот напрямую из репозитория. @riverpod -Future> enabledSourcePackages(Ref ref, String userId) => - ref.watch(sourceAppsRepositoryProvider).enabledPackages(userId); +Stream> enabledSourcePackages(Ref ref, String userId) => + ref.watch(sourceAppsRepositoryProvider).watchEnabledPackages(userId); + +/// Платформенный канал нативного слушателя уведомлений (Android). +@Riverpod(keepAlive: true) +NotificationListenerChannel notificationListenerChannel(Ref ref) => + NotificationListenerChannel(); + +/// Список установленных запускаемых приложений (для экрана выбора источников). +/// autoDispose: грузится при открытии пикера, освобождается после закрытия. +@riverpod +Future> installedApps(Ref ref) => + ref.watch(notificationListenerChannelProvider).getInstalledApps(); diff --git a/lib/src/features/notification_parsing/application/parsing_pipeline.dart b/lib/src/features/notification_parsing/application/parsing_pipeline.dart index 963f738..a85bdec 100644 --- a/lib/src/features/notification_parsing/application/parsing_pipeline.dart +++ b/lib/src/features/notification_parsing/application/parsing_pipeline.dart @@ -48,9 +48,13 @@ class ParsingPipeline { if (!settings.enabled) return; // фича выключена — оставляем pending. // Allowlist (§A): парсим только включённые приложения-источники. Делаем - // ДО AI, чтобы не тратить токены на посторонние пакеты. - final enabled = - await _ref.read(enabledSourcePackagesProvider(userId).future); + // ДО AI, чтобы не тратить токены на посторонние пакеты. Снапшот берём из + // репозитория напрямую (`.first` Drift-стрима): свежий на каждый вызов; + // stream-провайдер без прямого слушателя не подписался бы на стрим. + final enabled = await _ref + .read(sourceAppsRepositoryProvider) + .watchEnabledPackages(userId) + .first; if (!enabled.contains(msg.packageName)) { await _ref .read(rawMessagesRepositoryProvider) @@ -266,7 +270,7 @@ class ParsingPipeline { } } - // 6. Confidence. + // 6. Confidence — только для подсветки «?» в Inbox, gate не использует. final scores = scoreDraft( draft: draft, message: msg, @@ -276,24 +280,23 @@ class ParsingPipeline { categoryCandidate: candidate, ); - // 8. Gate. - final decision = decide( + // 8. Gate: чек-лист именованных проверок (decision_gate.dart). + final gate = decide( + autoApplyEnabled: settings.autoApplyEnabled, merchantRule: rule, - sanityPassed: true, - scores: scores, - strictness: settings.strictness, - amountMinor: draft.amount, - accountResolved: draft.accountId != null, - accountTrusted: resolution.trusted, + draft: draft, + message: msg, + resolution: resolution, ); - if (decision == GateDecision.autoApply) { + if (gate.decision == GateDecision.autoApply) { await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId); } else { await repo.updateAfterParse( id: msg.id, status: RawMessageStatus.inbox, - draftJson: encodeDraftBundle(draft, suggestion), + draftJson: + encodeDraftBundle(draft, suggestion, failedChecks: gate.failed), confidenceAmount: scores.amount, confidenceAccount: scores.account, confidenceType: scores.type, diff --git a/lib/src/features/notification_parsing/application/parsing_settings_controller.dart b/lib/src/features/notification_parsing/application/parsing_settings_controller.dart index e055d3b..fbc0117 100644 --- a/lib/src/features/notification_parsing/application/parsing_settings_controller.dart +++ b/lib/src/features/notification_parsing/application/parsing_settings_controller.dart @@ -10,7 +10,7 @@ part 'parsing_settings_controller.g.dart'; class ParsingSettings { const ParsingSettings({ required this.enabled, - required this.strictness, + required this.autoApplyEnabled, required this.aiConsentGiven, required this.aiModel, required this.aiDailyTokenLimit, @@ -20,8 +20,9 @@ class ParsingSettings { /// Распознавать уведомления (мастер-тумблер всей фичи). final bool enabled; - /// Порог строгости авто-добавления для прочих полей (§8.7): 75 / 85 / 95. - final int strictness; + /// Авто-добавление транзакций без подтверждения, когда пройден чек-лист + /// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart). + final bool autoApplyEnabled; /// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6). /// Без него AI не вызывается — работает только regex. @@ -41,7 +42,7 @@ class ParsingSettings { ParsingSettings copyWith({ bool? enabled, - int? strictness, + bool? autoApplyEnabled, bool? aiConsentGiven, String? aiModel, int? aiDailyTokenLimit, @@ -50,7 +51,7 @@ class ParsingSettings { }) => ParsingSettings( enabled: enabled ?? this.enabled, - strictness: strictness ?? this.strictness, + autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled, aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven, aiModel: aiModel ?? this.aiModel, aiDailyTokenLimit: @@ -60,7 +61,9 @@ class ParsingSettings { } const _kEnabled = 'parsing_enabled'; -const _kStrictness = 'parsing_auto_apply_strictness'; +// Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается: +// числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу. +const _kAutoApply = 'parsing_auto_apply_enabled'; const _kAiConsent = 'ai_consent'; const _kAiModel = 'ai_model'; const _kAiDailyLimit = 'ai_daily_token_limit'; @@ -69,7 +72,7 @@ const kDefaultAiModel = 'deepseek/deepseek-v4-flash'; const _defaultSettings = ParsingSettings( enabled: true, - strictness: 85, + autoApplyEnabled: true, aiConsentGiven: false, aiModel: kDefaultAiModel, aiDailyTokenLimit: null, @@ -91,7 +94,7 @@ class ParsingSettingsController extends _$ParsingSettingsController { Future build() async { final dao = ref.watch(appDatabaseProvider).settingsDao; final enabledStr = await dao.getPreference(_kEnabled); - final strictnessStr = await dao.getPreference(_kStrictness); + final autoApplyStr = await dao.getPreference(_kAutoApply); final consentStr = await dao.getPreference(_kAiConsent); final modelStr = await dao.getPreference(_kAiModel); final limitStr = await dao.getPreference(_kAiDailyLimit); @@ -100,8 +103,9 @@ class ParsingSettingsController extends _$ParsingSettingsController { return ParsingSettings( enabled: enabledStr == null ? _defaultSettings.enabled : enabledStr == 'true', - strictness: - int.tryParse(strictnessStr ?? '') ?? _defaultSettings.strictness, + autoApplyEnabled: autoApplyStr == null + ? _defaultSettings.autoApplyEnabled + : autoApplyStr == 'true', aiConsentGiven: consentStr == 'true', aiModel: (modelStr != null && modelStr.isNotEmpty) ? modelStr @@ -120,10 +124,10 @@ class ParsingSettingsController extends _$ParsingSettingsController { state = AsyncData(current.copyWith(enabled: value)); } - Future setStrictness(int value) async { - await _set(_kStrictness, '$value'); + Future setAutoApplyEnabled(bool value) async { + await _set(_kAutoApply, '$value'); final current = state.value ?? _defaultSettings; - state = AsyncData(current.copyWith(strictness: value)); + state = AsyncData(current.copyWith(autoApplyEnabled: value)); } Future setAiConsent(bool value) async { diff --git a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart index 8faa93b..61a9033 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart @@ -82,12 +82,17 @@ class RawMessagesDao extends DatabaseAccessor ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) .get(); - /// Поиск по dedupHash для идемпотентной вставки. + /// Поиск по dedupHash для идемпотентной вставки (§15): совпавший хэш + /// считается дублем только в окне `receivedAt ∈ [from, to]` — иначе + /// регулярные одинаковые уведомления (подписка, ежедневный кофе) + /// схлопывались бы навсегда. Future findByDedupHash( - String userId, String dedupHash) => + String userId, String dedupHash, DateTime from, DateTime to) => (select(rawMessagesTable) ..where((t) => - t.userId.equals(userId) & t.dedupHash.equals(dedupHash)) + t.userId.equals(userId) & + t.dedupHash.equals(dedupHash) & + t.receivedAt.isBetweenValues(from, to)) ..limit(1)) .getSingleOrNull(); diff --git a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart index 96d53c6..6bc9914 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart @@ -16,13 +16,13 @@ class SourceAppsDao extends DatabaseAccessor ..orderBy([(t) => OrderingTerm(expression: t.displayName)])) .watch(); - /// Включённые packageName — для allowlist-фильтра. - Future> enabledPackages(String userId) async { - final rows = await (select(sourceAppsTable) - ..where((t) => t.userId.equals(userId) & t.enabled.equals(true))) - .get(); - return rows.map((r) => r.packageName).toList(); - } + /// Включённые packageName — для allowlist-фильтра. Стрим: эмитит при любом + /// изменении таблицы, чтобы allowlist не протухал до перезапуска. + Stream> watchEnabledPackages(String userId) => + (select(sourceAppsTable) + ..where((t) => t.userId.equals(userId) & t.enabled.equals(true))) + .watch() + .map((rows) => rows.map((r) => r.packageName).toList()); Future findByPackageName( String userId, diff --git a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart index c337a32..b602aeb 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import '../../../../../core/database/converters/enum_converters.dart'; import '../../../../../core/database/tables/users_table.dart'; import '../../../../../core/database/tables/accounts_table.dart'; import '../../../../../core/database/tables/categories_table.dart'; @@ -32,6 +33,12 @@ class ParseRulesTable extends Table { IntColumn get weight => integer().withDefault(const Constant(1))(); DateTimeColumn get lastMatchAt => dateTime().nullable()(); + /// Тип операции, зафиксированный при создании правила (merchantToCategory). + /// Gate сверяет с ним тип из AI-draft; null (легаси-правила) = проверка + /// пропускается. + TextColumn get txType => + text().map(const TransactionTypeConverter()).nullable()(); + // Action fields (заполняются в зависимости от kind): TextColumn get merchantCanonical => text().nullable()(); TextColumn get categoryId => text() diff --git a/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart b/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart index de5c38d..4ed290e 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart @@ -6,7 +6,9 @@ import '../converters.dart'; /// Сырые входящие уведомления от банковских приложений. /// /// Точка входа pipeline парсинга. Идемпотентность обеспечивается -/// полем [dedupHash] = hash(packageName + body). +/// полем [dedupHash] = hash(packageName + body) в паре с проверкой +/// `receivedAt` в пределах дедуп-окна (§15) — хэш сам по себе не уникален: +/// одинаковые регулярные уведомления в разные дни являются разными записями. class RawMessagesTable extends Table { @override String get tableName => 'raw_messages'; diff --git a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart index b414372..d87bd2b 100644 --- a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart @@ -13,6 +13,7 @@ extension ParseRuleMapper on ParseRulesTableData { matchCount: matchCount, weight: weight, lastMatchAt: lastMatchAt, + txType: txType, merchantCanonical: merchantCanonical, categoryId: categoryId, accountId: accountId, diff --git a/lib/src/features/notification_parsing/data/native/notification_listener_channel.dart b/lib/src/features/notification_parsing/data/native/notification_listener_channel.dart new file mode 100644 index 0000000..e33719b --- /dev/null +++ b/lib/src/features/notification_parsing/data/native/notification_listener_channel.dart @@ -0,0 +1,111 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +/// Установленное на устройстве приложение (для выбора источника уведомлений). +class InstalledApp { + const InstalledApp({ + required this.packageName, + required this.appName, + this.icon, + }); + + final String packageName; + final String appName; + + /// Иконка приложения в PNG (если нативный слой смог её отрисовать). + final Uint8List? icon; +} + +/// Одно пойманное нативным слоем уведомление. +class NativeNotification { + const NativeNotification({ + required this.packageName, + this.title, + required this.body, + required this.receivedAt, + }); + + final String packageName; + final String? title; + final String body; + final DateTime receivedAt; +} + +/// Обёртка платформенных каналов нативного слушателя уведомлений (Android). +/// +/// Это платформенная реализация источника данных, поэтому живёт в data-слое +/// (как Drift). Очередь на нативной стороне — источник истины: [drainPending] +/// забирает и очищает её, [ticks] лишь сигнализирует «появилось новое, пора +/// дренировать». На не-Android платформах (и в `flutter test`) всё no-op, +/// чтобы код не падал без нативного канала. +class NotificationListenerChannel { + static const MethodChannel _method = + MethodChannel('com.sanders.budget/notifications'); + static const EventChannel _events = + EventChannel('com.sanders.budget/notifications/events'); + + bool get _supported => Platform.isAndroid; + + /// Выдан ли пользователем доступ «Чтение уведомлений». + Future isPermissionGranted() async { + if (!_supported) return false; + final granted = await _method.invokeMethod('isPermissionGranted'); + return granted ?? false; + } + + /// Открывает системный экран «Доступ к уведомлениям». + Future openSettings() async { + if (!_supported) return; + await _method.invokeMethod('openSettings'); + } + + /// Синхронизирует allowlist мониторимых пакетов на нативную сторону. + Future setMonitoredPackages(Set packages) async { + if (!_supported) return; + await _method.invokeMethod( + 'setMonitoredPackages', + packages.toList(growable: false), + ); + } + + /// Забирает и очищает нативную очередь пойманных уведомлений. + Future> drainPending() async { + if (!_supported) return const []; + final raw = await _method.invokeListMethod('drainPending'); + if (raw == null) return const []; + return raw.map((e) { + final map = (e as Map).cast(); + final title = map['title'] as String?; + return NativeNotification( + packageName: (map['packageName'] as String?) ?? '', + title: (title != null && title.isEmpty) ? null : title, + body: (map['body'] as String?) ?? '', + receivedAt: DateTime.fromMillisecondsSinceEpoch( + (map['receivedAt'] as num?)?.toInt() ?? 0, + ), + ); + }).where((n) => n.packageName.isNotEmpty && n.body.isNotEmpty).toList(); + } + + /// Список запускаемых приложений устройства (для выбора источника вручную). + Future> getInstalledApps() async { + if (!_supported) return const []; + final raw = await _method.invokeListMethod('getInstalledApps'); + if (raw == null) return const []; + return raw.map((e) { + final map = (e as Map).cast(); + return InstalledApp( + packageName: (map['packageName'] as String?) ?? '', + appName: (map['appName'] as String?) ?? '', + icon: map['icon'] as Uint8List?, + ); + }).where((a) => a.packageName.isNotEmpty).toList(); + } + + /// Поток сигналов-тиков «появилось новое уведомление, продренируй очередь». + Stream ticks() { + if (!_supported) return const Stream.empty(); + return _events.receiveBroadcastStream(); + } +} diff --git a/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart b/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart index 44ce356..977196a 100644 --- a/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart +++ b/lib/src/features/notification_parsing/data/parser/confidence_scorer.dart @@ -5,6 +5,9 @@ import '../../domain/entities/rule_candidate.dart'; import '../../domain/enums.dart'; /// Пять per-field оценок уверенности (0..100), сохраняются в `raw_messages`. +/// +/// В gate НЕ участвуют: авто-применение решает чек-лист [AutoApplyCheck] +/// в `decision_gate.dart`. Оценки нужны только для подсветки «?» в Inbox. class FieldScores { const FieldScores({ required this.amount, @@ -19,19 +22,9 @@ class FieldScores { final int type; final int merchant; final int category; - - /// Минимум по «прочим полям» — это и есть вход в gate (§8.7). - /// - /// Счёт исключён из 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). -/// -/// Confidence играет вспомогательную роль: судьбу мерчанта решает наличие -/// правила (§5), а эти оценки гейтят прочие поля и дают подсветку «?». +/// Считает 5 per-field оценок (§8.1–8.6) — корм для бэджа «?» в Inbox. /// /// Источник draft влияет на amount/type: regex выдаёт максимум (100), AI — /// меньше (§8.1/§8.3), т.к. возможны галлюцинации. @@ -49,7 +42,7 @@ FieldScores scoreDraft({ int amount; int type; if (draft.source == ParseSource.ai) { - amount = _amountAppearsInBody(draft.amount, message.body) ? 60 : 10; + amount = amountAppearsInBody(draft.amount, message.body) ? 60 : 10; type = 70; } else { amount = 100; @@ -120,14 +113,16 @@ int _scoreCategory( int _capAt(int value, int cap) => value > cap ? cap : value; -/// Грубая проверка §8.1 для AI: встречается ли вытащенная сумма в теле. -/// Сверяем целую часть (рубли) — десятичные банк может опускать/округлять. +/// Детерминированная верификация суммы: встречается ли вытащенная сумма в +/// теле сообщения. Используется gate-проверкой `amountVerifiedInBody` и +/// скорингом §8.1. Сверяем целую часть (рубли) — десятичные банк может +/// опускать/округлять. /// /// Сравниваем **числа целиком**, а не подстроку: иначе сумма 50 ложно /// «находилась» бы в номере карты *5012 или в любой группе цифр, содержащей /// «50». Вытаскиваем из тела числовые токены (с разделителями тысяч/копеек), /// отбрасываем дробную часть и сравниваем как целые. -bool _amountAppearsInBody(int amountMinor, String body) { +bool amountAppearsInBody(int amountMinor, String body) { final major = amountMinor ~/ 100; if (major <= 0) return false; for (final m in RegExp(r'\d[\d\s.,]*\d|\d').allMatches(body)) { diff --git a/lib/src/features/notification_parsing/data/parser/decision_gate.dart b/lib/src/features/notification_parsing/data/parser/decision_gate.dart index 30264e3..3a49d47 100644 --- a/lib/src/features/notification_parsing/data/parser/decision_gate.dart +++ b/lib/src/features/notification_parsing/data/parser/decision_gate.dart @@ -1,40 +1,84 @@ +import '../../domain/entities/parse_draft.dart'; import '../../domain/entities/parse_rule.dart'; +import '../../domain/entities/raw_message.dart'; +import 'account_resolver.dart'; import 'confidence_scorer.dart'; /// Решение gate: молча в ленту или в Inbox. enum GateDecision { autoApply, inbox } +/// Именованные проверки авто-применения (§8.7). Auto-apply — конъюнкция всех: +/// никакого числового скоринга, каждая проверка детерминирована и объяснима +/// пользователю («почему не автоматически»). +enum AutoApplyCheck { + /// Есть пользовательское правило для мерчанта. + ruleMatched, + + /// Извлечённая сумма дословно найдена в теле сообщения. + amountVerifiedInBody, + + /// Валюта распознана. + currencyKnown, + + /// Тип операции совпадает с зафиксированным при создании правила + /// (null у легаси-правил — проверка пропускается). + typeMatchesRule, + + /// Счёт разрешён (binding / правило / дефолт). + accountResolved, + + /// Счёту можно доверять для авто-применения (не ambiguous multi-binding). + accountTrusted, + + /// Сумма ниже потолка — защита от галлюцинаций (§15). + amountUnderCap, +} + +/// Результат gate: решение + какие проверки не прошли (для лога/Inbox). +class GateResult { + const GateResult({required this.decision, required this.failed}); + + final GateDecision decision; + + /// Непройденные проверки. Пустое множество при [GateDecision.autoApply] + /// и при выключенном тумблере (проверки не оценивались). + final Set failed; +} + /// Очень крупная сумма (₽) — всегда Inbox, защита от галлюцинаций (§15). /// Минорные единицы: 100 000 ₽ = 10 000 000 копеек. const int _hugeAmountMinor = 100000 * 100; -/// Decision gate (§8.7). -/// -/// Молча в ленту только если есть подтверждённое правило для мерчанта, -/// sanity пройден, счёт разрешён и доверенный, и min(сумма, тип) ≥ порога -/// строгости. Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту. -/// -/// [accountResolved] — счёт вообще нашёлся; [accountTrusted] — ему можно -/// доверять для авто-применения (§B): неоднозначные multi-binding (#5) дают -/// false и уходят в Inbox, осознанные дефолты (#4/#6) — true. -GateDecision decide({ +/// Decision gate (§8.7): молча в ленту только если авто-добавление включено +/// и пройдены ВСЕ проверки [AutoApplyCheck]. Проверки не short-circuit — +/// собираем полный список непройденных, чтобы показать причину в Inbox/логе. +GateResult decide({ + required bool autoApplyEnabled, required ParseRule? merchantRule, - required bool sanityPassed, - required FieldScores scores, - required int strictness, - required int amountMinor, - required bool accountResolved, - required bool accountTrusted, + required ParseDraft draft, + required RawMessage message, + required AccountResolution resolution, }) { - if (amountMinor > _hugeAmountMinor) return GateDecision.inbox; - - // Без разрешённого/доверенного счёта нечем создать транзакцию молча. - if (!accountResolved || !accountTrusted) return GateDecision.inbox; - - if (merchantRule != null && - sanityPassed && - scores.otherFieldsMin >= strictness) { - return GateDecision.autoApply; + if (!autoApplyEnabled) { + return const GateResult(decision: GateDecision.inbox, failed: {}); } - return GateDecision.inbox; + + final failed = { + if (merchantRule == null) AutoApplyCheck.ruleMatched, + if (!amountAppearsInBody(draft.amount, message.body)) + AutoApplyCheck.amountVerifiedInBody, + if (draft.currency.isEmpty) AutoApplyCheck.currencyKnown, + if (merchantRule != null && + merchantRule.txType != null && + merchantRule.txType != draft.type) + AutoApplyCheck.typeMatchesRule, + if (draft.accountId == null) AutoApplyCheck.accountResolved, + if (!resolution.trusted) AutoApplyCheck.accountTrusted, + if (draft.amount > _hugeAmountMinor) AutoApplyCheck.amountUnderCap, + }; + + return GateResult( + decision: failed.isEmpty ? GateDecision.autoApply : GateDecision.inbox, + failed: failed, + ); } diff --git a/lib/src/features/notification_parsing/data/parser/dedup.dart b/lib/src/features/notification_parsing/data/parser/dedup.dart index e357360..cc1f76a 100644 --- a/lib/src/features/notification_parsing/data/parser/dedup.dart +++ b/lib/src/features/notification_parsing/data/parser/dedup.dart @@ -1,3 +1,10 @@ +/// Окно дедупликации (§15): совпавший [computeDedupHash] считается дублем +/// только если `receivedAt` обоих сообщений различается не более чем на это +/// окно. `receivedAt` берётся из `sbn.postTime`, поэтому у переотправленного +/// дубля (ребут, повторный дрейн очереди) время совпадает с оригиналом, а у +/// настоящей новой операции с тем же текстом — отличается на часы/дни. +const Duration kDedupWindow = Duration(minutes: 3); + /// Детерминированный хэш для дедупликации уведомлений (§15). /// /// `String.hashCode` в Dart рандомизируется на каждый запуск изолята, поэтому @@ -5,7 +12,10 @@ /// стабильный FNV-1a (64-bit) и кодируем в hex. Без внешних зависимостей. /// /// Хэш считается по `packageName + '\n' + body` — **без времени**, чтобы -/// повторная доставка того же сообщения схлопывалась в одну запись. +/// повторная доставка того же сообщения схлопывалась в одну запись. Сам по +/// себе хэш дублем не считается: вставка дополнительно проверяет +/// `receivedAt` в пределах [kDedupWindow], иначе регулярные одинаковые +/// операции (подписка, ежедневный кофе) терялись бы навсегда. String computeDedupHash(String packageName, String body) { final input = '$packageName\n$body'; return _fnv1a64(input); diff --git a/lib/src/features/notification_parsing/data/parser/draft_codec.dart b/lib/src/features/notification_parsing/data/parser/draft_codec.dart index e70674e..51c7cf1 100644 --- a/lib/src/features/notification_parsing/data/parser/draft_codec.dart +++ b/lib/src/features/notification_parsing/data/parser/draft_codec.dart @@ -4,6 +4,7 @@ import '../../../../core/database/converters/enum_converters.dart'; import '../../domain/entities/parse_draft.dart'; import '../../domain/entities/rule_suggestion.dart'; import '../../domain/enums.dart'; +import 'decision_gate.dart'; /// Сериализация связки `ParseDraft` + `RuleSuggestion` в строку для /// `RawMessage.draftJson`. Проект не использует json_serializable — @@ -12,16 +13,30 @@ import '../../domain/enums.dart'; /// Связка кешируется при отправке сообщения в Inbox, чтобы экран мог /// восстановить предложение без повторного парсинга. class DraftBundle { - const DraftBundle({required this.draft, this.suggestion}); + const DraftBundle({ + required this.draft, + this.suggestion, + this.failedChecks = const {}, + }); final ParseDraft draft; final RuleSuggestion? suggestion; + + /// Gate-проверки, не пройденные при отправке в Inbox — для строки + /// «почему не автоматически» в карточке/журнале. + final Set failedChecks; } -String encodeDraftBundle(ParseDraft draft, RuleSuggestion? suggestion) { +String encodeDraftBundle( + ParseDraft draft, + RuleSuggestion? suggestion, { + Set failedChecks = const {}, +}) { return jsonEncode({ 'draft': _draftToJson(draft), if (suggestion != null) 'suggestion': _suggestionToJson(suggestion), + if (failedChecks.isNotEmpty) + 'failedChecks': failedChecks.map((c) => c.name).toList(), }); } @@ -34,9 +49,18 @@ DraftBundle? decodeDraftBundle(String? json) { return DraftBundle( draft: _draftFromJson(draftMap), suggestion: suggMap == null ? null : _suggestionFromJson(suggMap), + failedChecks: _failedChecksFromJson(map['failedChecks']), ); } +/// Неизвестные имена проверок (например, после удаления enum-значения в новой +/// версии) молча пропускаем — это кеш, а не источник истины. +Set _failedChecksFromJson(Object? raw) { + if (raw is! List) return const {}; + final byName = {for (final c in AutoApplyCheck.values) c.name: c}; + return raw.whereType().map((n) => byName[n]).nonNulls.toSet(); +} + Map _draftToJson(ParseDraft d) => { 'rawMessageId': d.rawMessageId, 'type': d.type.name, diff --git a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart index 1ebf463..97f163a 100644 --- a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../../../../core/database/app_database.dart'; +import '../../../../core/database/converters/enum_converters.dart'; import '../../domain/entities/parse_rule.dart'; import '../../domain/enums.dart'; import '../../domain/repositories/parse_rules_repository.dart'; @@ -36,6 +37,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { required MatchMode matchMode, required String pattern, int priority = 0, + TransactionType? txType, String? merchantCanonical, String? categoryId, String? accountId, @@ -49,6 +51,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { pattern: pattern, matchMode: Value(matchMode), priority: Value(priority), + txType: Value(txType), merchantCanonical: Value(merchantCanonical), categoryId: Value(categoryId), accountId: Value(accountId), @@ -66,6 +69,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { matchMode: Value(rule.matchMode), pattern: Value(rule.pattern), priority: Value(rule.priority), + txType: Value(rule.txType), merchantCanonical: Value(rule.merchantCanonical), categoryId: Value(rule.categoryId), accountId: Value(rule.accountId), diff --git a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart index 080be5b..80703ae 100644 --- a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart @@ -40,8 +40,15 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { (await _dao.findById(id))?.toDomain(); @override - Future findByDedupHash(String userId, String dedupHash) async => - (await _dao.findByDedupHash(userId, dedupHash))?.toDomain(); + Future findByDedupHash( + String userId, String dedupHash, DateTime receivedAt) async => + (await _dao.findByDedupHash( + userId, + dedupHash, + receivedAt.subtract(kDedupWindow), + receivedAt.add(kDedupWindow), + )) + ?.toDomain(); @override Future> recentByUser(String userId, DateTime since) async => @@ -58,8 +65,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { required DateTime receivedAt, }) async { final hash = computeDedupHash(packageName, body); - final existing = await _dao.findByDedupHash(userId, hash); - if (existing != null) return existing.toDomain(); + // §15: дубль = тот же хэш И receivedAt в пределах ±kDedupWindow. Тот же + // текст вне окна — новая реальная операция (подписка, регулярный платёж). + final existing = await findByDedupHash(userId, hash, receivedAt); + if (existing != null) return existing; final id = const Uuid().v4(); await _dao.insert( diff --git a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart index be69ce4..7dccaac 100644 --- a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart @@ -17,8 +17,8 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository { .map((rows) => rows.map((r) => r.toDomain()).toList()); @override - Future> enabledPackages(String userId) async => - (await _dao.enabledPackages(userId)).toSet(); + Stream> watchEnabledPackages(String userId) => + _dao.watchEnabledPackages(userId).map((rows) => rows.toSet()); @override Future add({ diff --git a/lib/src/features/notification_parsing/domain/entities/parse_rule.dart b/lib/src/features/notification_parsing/domain/entities/parse_rule.dart index 2aa666a..8aa5d7c 100644 --- a/lib/src/features/notification_parsing/domain/entities/parse_rule.dart +++ b/lib/src/features/notification_parsing/domain/entities/parse_rule.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../../../core/database/converters/enum_converters.dart'; import '../enums.dart'; part 'parse_rule.freezed.dart'; @@ -27,6 +28,10 @@ abstract class ParseRule with _$ParseRule { @Default(1) int weight, DateTime? lastMatchAt, + /// Тип операции, зафиксированный при создании правила (merchantToCategory): + /// gate сверяет с ним тип AI-draft. null (легаси) — проверка пропускается. + TransactionType? txType, + // Action fields (nullable, depend on kind): String? merchantCanonical, String? categoryId, diff --git a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart index 7d392bc..6d34395 100644 --- a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart @@ -1,3 +1,4 @@ +import '../../../../core/database/converters/enum_converters.dart'; import '../entities/parse_rule.dart'; import '../enums.dart'; @@ -20,6 +21,7 @@ abstract interface class ParseRulesRepository { required MatchMode matchMode, required String pattern, int priority, + TransactionType? txType, String? merchantCanonical, String? categoryId, String? accountId, diff --git a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart index 967d4f1..3ae51cf 100644 --- a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart @@ -24,13 +24,19 @@ abstract interface class RawMessagesRepository { Future findById(String id); - Future findByDedupHash(String userId, String dedupHash); + /// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах + /// дедуп-окна вокруг [receivedAt] (§15). Совпадение только по хэшу дублем + /// не считается — то же уведомление завтра является новой операцией. + Future findByDedupHash( + String userId, String dedupHash, DateTime receivedAt); /// Сообщения за период (для live-превью правил). Future> recentByUser(String userId, DateTime since); /// Идемпотентная вставка нового уведомления. Если сообщение с тем же - /// [dedupHash] уже есть — возвращает существующее, не создавая дубль. + /// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** — + /// возвращает существующее, не создавая дубль. Тот же текст вне окна + /// (регулярный платёж) вставляется как новое сообщение. Future insertIncoming({ required String userId, required String packageName, diff --git a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart index 1a72810..e7fd709 100644 --- a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart @@ -6,7 +6,8 @@ abstract interface class SourceAppsRepository { Stream> watchByUser(String userId); /// Множество включённых packageName — для allowlist-фильтра в пайплайне. - Future> enabledPackages(String userId); + /// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist. + Stream> watchEnabledPackages(String userId); /// Добавляет приложение (включено по умолчанию). Возвращает сущность. /// Если строка с таким packageName уже есть — возвращает существующую. diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart index a0a0785..ef210ff 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart @@ -13,6 +13,7 @@ import '../../data/parser/draft_codec.dart'; import '../../domain/entities/raw_message.dart'; import '../../domain/enums.dart'; import '../widgets/confidence_badge.dart'; +import '../widgets/gate_check_labels.dart'; /// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7). const _maxParseAttempts = 5; @@ -355,6 +356,13 @@ class _DetailPanel extends StatelessWidget { ), if (draft != null) _kv(context, l10n.parsingDetailSource, draft.source.name), + if (bundle != null && bundle!.failedChecks.isNotEmpty) + _kv( + context, + l10n.parsingDetailFailedChecks, + bundle!.failedChecks + .map((c) => gateCheckLabel(context, c)) + .join(', ')), ]), _section(context, l10n.parsingDetailMessage, [ _kv(context, l10n.parsingDetailTitle, message.title), diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart index 5984aca..d684fd5 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart @@ -7,12 +7,13 @@ 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/notification_access_controller.dart'; import '../../application/parsing_settings_controller.dart'; import '../../application/rules_controller.dart'; import '../../domain/entities/parse_rule.dart'; -/// Экран настроек парсинга (§12.5). Phase 1: тумблер фичи + строгость -/// авто-добавления + вход в правила. AI-секция — заглушка под Phase 2. +/// Экран настроек парсинга (§12.5): тумблер фичи + тумблер авто-добавления +/// (чек-лист gate-проверок) + вход в правила + AI-секция. class ParsingSettingsScreen extends ConsumerWidget { const ParsingSettingsScreen({super.key}); @@ -53,17 +54,27 @@ class ParsingSettingsScreen extends ConsumerWidget { ), ], ), + if (defaultTargetPlatform == TargetPlatform.android) ...[ + const SizedBox(height: 16), + const _NotificationAccessCard(), + ], const SizedBox(height: 16), - Text(l10n.parsingStrictnessLabel, - style: TextStyle(fontSize: 13, color: p.ink2)), - const SizedBox(height: 8), - _StrictnessSelector( - value: settings?.strictness ?? 85, - onChanged: (v) => controller.setStrictness(v), + _Card( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + title: Text(l10n.parsingAutoApplyLabel, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text(l10n.parsingAutoApplyHint, + style: TextStyle(fontSize: 12, color: p.ink2)), + value: settings?.autoApplyEnabled ?? true, + activeThumbColor: p.accent, + onChanged: settings == null + ? null + : (v) => controller.setAutoApplyEnabled(v), + ), + ], ), - const SizedBox(height: 6), - Text(l10n.parsingStrictnessHint, - style: TextStyle(fontSize: 12, color: p.ink2)), const SizedBox(height: 16), _Card( children: [ @@ -148,24 +159,74 @@ class ParsingSettingsScreen extends ConsumerWidget { } } -class _StrictnessSelector extends StatelessWidget { - const _StrictnessSelector({required this.value, required this.onChanged}); +/// Карточка доступа «Чтение уведомлений» (Android). Показывает статус и ведёт +/// в системные настройки; при возврате в приложение статус перечитывается. +class _NotificationAccessCard extends ConsumerStatefulWidget { + const _NotificationAccessCard(); - final int value; - final ValueChanged onChanged; + @override + ConsumerState<_NotificationAccessCard> createState() => + _NotificationAccessCardState(); +} + +class _NotificationAccessCardState + extends ConsumerState<_NotificationAccessCard> with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.read(notificationAccessControllerProvider.notifier).refresh(); + } + } @override Widget build(BuildContext context) { + final p = context.palette; final l10n = context.l10n; - return SegmentedButton( - segments: [ - ButtonSegment(value: 75, label: Text(l10n.parsingStrictnessSoft)), - ButtonSegment(value: 85, label: Text(l10n.parsingStrictnessNormal)), - ButtonSegment(value: 95, label: Text(l10n.parsingStrictnessStrict)), + final granted = ref.watch(notificationAccessStatusProvider).value ?? false; + + return _Card( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: Icon( + granted ? Icons.notifications_active_outlined : Icons.notifications_off_outlined, + color: granted ? p.positive : p.ink2, + ), + title: Text(l10n.parsingNotifAccessTitle, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text( + granted + ? l10n.parsingNotifAccessGranted + : l10n.parsingNotifAccessDenied, + style: TextStyle(fontSize: 12, color: p.ink2), + ), + trailing: granted + ? null + : TextButton( + onPressed: () => ref + .read(notificationAccessControllerProvider.notifier) + .openSettings(), + child: Text(l10n.parsingNotifAccessOpenSettings), + ), + onTap: granted + ? null + : () => ref + .read(notificationAccessControllerProvider.notifier) + .openSettings(), + ), ], - selected: {value}, - showSelectedIcon: false, - onSelectionChanged: (s) => onChanged(s.first), ); } } diff --git a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart index 6671ce1..3d1f2bd 100644 --- a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -6,6 +7,7 @@ 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/notification_parsing_providers.dart'; import '../../application/source_apps_controller.dart'; import '../../data/source_apps/source_apps_catalog.dart'; import '../../domain/entities/source_app.dart'; @@ -51,6 +53,32 @@ class SourceAppsScreen extends ConsumerWidget { body: ListView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [ + if (defaultTargetPlatform == TargetPlatform.android) ...[ + _Card( + children: [ + InkWell( + onTap: () => _pickFromInstalled(context, ref, userId), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + Icon(Icons.phone_android_outlined, + size: 20, color: p.accent), + const SizedBox(width: 12), + Expanded( + child: Text(l10n.sourceAppsPickFromInstalled, + style: TextStyle(fontSize: 14, color: p.ink)), + ), + Icon(Icons.chevron_right, size: 18, color: p.ink2), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 20), + ], Text(l10n.sourceAppsAddedSection, style: TextStyle(fontSize: 13, color: p.ink2)), const SizedBox(height: 8), @@ -140,6 +168,157 @@ class SourceAppsScreen extends ConsumerWidget { ); } } + + Future _pickFromInstalled( + BuildContext context, + WidgetRef ref, + String userId, + ) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: context.palette.paper, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => _InstalledAppsPickerSheet(userId: userId), + ); + } +} + +/// Лист выбора установленного приложения: поиск + список с иконками. +/// Уже добавленные пакеты помечаются и не добавляются повторно. +class _InstalledAppsPickerSheet extends ConsumerStatefulWidget { + const _InstalledAppsPickerSheet({required this.userId}); + + final String userId; + + @override + ConsumerState<_InstalledAppsPickerSheet> createState() => + _InstalledAppsPickerSheetState(); +} + +class _InstalledAppsPickerSheetState + extends ConsumerState<_InstalledAppsPickerSheet> { + String _query = ''; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final installed = ref.watch(installedAppsProvider); + final added = { + for (final a in ref.watch(sourceAppsListProvider(widget.userId)).value ?? + const []) + a.packageName, + }; + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Column( + children: [ + const SizedBox(height: 12), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: p.line, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text(l10n.sourceAppsPickerTitle, + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: p.ink)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + autofocus: true, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search, size: 20), + hintText: l10n.sourceAppsSearchHint, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onChanged: (v) => setState(() => _query = v.trim().toLowerCase()), + ), + ), + const SizedBox(height: 8), + Expanded( + child: installed.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (_, _) => Center( + child: Text(l10n.sourceAppsPickerEmpty, + style: TextStyle(fontSize: 14, color: p.ink2)), + ), + data: (apps) { + final filtered = _query.isEmpty + ? apps + : apps + .where((a) => + a.appName.toLowerCase().contains(_query) || + a.packageName.toLowerCase().contains(_query)) + .toList(); + if (filtered.isEmpty) { + return Center( + child: Text(l10n.sourceAppsPickerEmpty, + style: TextStyle(fontSize: 14, color: p.ink2)), + ); + } + return ListView.builder( + controller: scrollController, + itemCount: filtered.length, + itemBuilder: (context, i) { + final app = filtered[i]; + final isAdded = added.contains(app.packageName); + return ListTile( + leading: app.icon != null + ? Image.memory(app.icon!, + width: 36, height: 36, gaplessPlayback: true) + : Icon(Icons.apps_outlined, color: p.ink2), + title: Text( + app.appName.isEmpty ? app.packageName : app.appName, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text(app.packageName, + style: TextStyle(fontSize: 11, color: p.ink2)), + trailing: isAdded + ? Text(l10n.sourceAppsAlreadyAdded, + style: + TextStyle(fontSize: 12, color: p.positive)) + : Icon(Icons.add_circle_outline, color: p.accent), + onTap: isAdded + ? null + : () async { + await ref + .read(sourceAppsControllerProvider.notifier) + .add( + userId: widget.userId, + packageName: app.packageName, + displayName: app.appName.isEmpty + ? null + : app.appName, + ); + if (context.mounted) Navigator.of(context).pop(); + }, + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } } class _AddedAppTile extends ConsumerWidget { diff --git a/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart b/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart new file mode 100644 index 0000000..1081c6c --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../app/l10n/l10n.dart'; +import '../../data/parser/decision_gate.dart'; + +/// Локализованное название непройденной gate-проверки — +/// для строки «почему не автоматически» в Inbox и журнале парсинга. +String gateCheckLabel(BuildContext context, AutoApplyCheck check) { + final l10n = context.l10n; + switch (check) { + case AutoApplyCheck.ruleMatched: + return l10n.gateCheckRuleMatched; + case AutoApplyCheck.amountVerifiedInBody: + return l10n.gateCheckAmountVerifiedInBody; + case AutoApplyCheck.currencyKnown: + return l10n.gateCheckCurrencyKnown; + case AutoApplyCheck.typeMatchesRule: + return l10n.gateCheckTypeMatchesRule; + case AutoApplyCheck.accountResolved: + return l10n.gateCheckAccountResolved; + case AutoApplyCheck.accountTrusted: + return l10n.gateCheckAccountTrusted; + case AutoApplyCheck.amountUnderCap: + return l10n.gateCheckAmountUnderCap; + } +} + +/// Текст «Почему не автоматически: …» для Inbox-карточки. +/// +/// Показываем только когда правило для мерчанта НАШЛОСЬ, но другие проверки +/// не прошли — пользователь ждал авто-применения и должен увидеть причину. +/// Без правила Inbox — ожидаемое место, причины не нужны. +String? whyNotAutoText(BuildContext context, Set failed) { + if (failed.isEmpty || failed.contains(AutoApplyCheck.ruleMatched)) { + return null; + } + final reasons = + failed.map((c) => gateCheckLabel(context, c)).join(', '); + return context.l10n.inboxWhyNotAuto(reasons); +} diff --git a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart index 7e11a05..13e44d2 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart @@ -14,6 +14,7 @@ import '../../domain/entities/raw_message.dart'; import '../../domain/enums.dart'; import '../screens/rule_editor_screen.dart'; import 'confidence_badge.dart'; +import 'gate_check_labels.dart'; /// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три /// действия — «Создать правило», «Подтвердить разово», «Игнорировать». @@ -148,6 +149,13 @@ class _RecognizedBody extends ConsumerWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), + if (whyNotAutoText(context, bundle.failedChecks) != null) ...[ + const SizedBox(height: 6), + Text( + whyNotAutoText(context, bundle.failedChecks)!, + style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3), + ), + ], const SizedBox(height: 12), _CreateRuleButton( label: categoryName != null diff --git a/lib/src/shared/widgets/app_scaffold.dart b/lib/src/shared/widgets/app_scaffold.dart index 4934a22..dbdaf45 100644 --- a/lib/src/shared/widgets/app_scaffold.dart +++ b/lib/src/shared/widgets/app_scaffold.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../features/notification_parsing/application/notification_ingest_worker.dart'; import '../../features/notification_parsing/application/parsing_worker.dart'; import '../../features/user/application/active_user_controller.dart'; import 'app_bottom_nav.dart'; @@ -20,6 +22,10 @@ class AppScaffold extends ConsumerWidget { final userId = ref.watch(activeUserControllerProvider).value?.id; if (userId != null) { ref.watch(parsingWorkerProvider(userId)); + // Нативный слушатель уведомлений — только Android. + if (defaultTargetPlatform == TargetPlatform.android) { + ref.watch(notificationIngestWorkerProvider(userId)); + } } return Scaffold( diff --git a/test/core/database/migration_v6_test.dart b/test/core/database/migration_v6_test.dart index 3f9d4ec..f48d813 100644 --- a/test/core/database/migration_v6_test.dart +++ b/test/core/database/migration_v6_test.dart @@ -42,8 +42,8 @@ void main() { ), ); - // 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7 - // (иначе onUpgrade 5→7 попытается создать их повторно). + // 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7/v8 + // (иначе onUpgrade 5→8 попытается создать их повторно). await dbV6.customStatement( 'ALTER TABLE transactions DROP COLUMN obligation'); await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse'); @@ -52,6 +52,7 @@ void main() { await dbV6.customStatement( 'ALTER TABLE account_bindings DROP COLUMN is_default'); await dbV6.customStatement('DROP TABLE source_apps'); + await dbV6.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type'); await dbV6.customStatement('PRAGMA user_version = 5'); await dbV6.close(); diff --git a/test/core/database/migration_v7_test.dart b/test/core/database/migration_v7_test.dart index b16c8ca..f4d40c9 100644 --- a/test/core/database/migration_v7_test.dart +++ b/test/core/database/migration_v7_test.dart @@ -40,10 +40,11 @@ void main() { ), ); - // 2. «Откатываем» до v6. + // 2. «Откатываем» до v6 (включая артефакт v8 — parse_rules.tx_type). await dbV7.customStatement( 'ALTER TABLE account_bindings DROP COLUMN is_default'); await dbV7.customStatement('DROP TABLE source_apps'); + await dbV7.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type'); await dbV7.customStatement('PRAGMA user_version = 6'); await dbV7.close(); @@ -76,7 +77,8 @@ void main() { packageName: 'ru.sberbankmobile', ), ); - final enabled = await dbMigrated.sourceAppsDao.enabledPackages(userId); + final enabled = + await dbMigrated.sourceAppsDao.watchEnabledPackages(userId).first; expect(enabled, contains('ru.sberbankmobile')); }); } diff --git a/test/core/database/migration_v8_test.dart b/test/core/database/migration_v8_test.dart new file mode 100644 index 0000000..36dc378 --- /dev/null +++ b/test/core/database/migration_v8_test.dart @@ -0,0 +1,73 @@ +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'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; + +/// Тест миграции v7 → v8 (parse_rules.tx_type для gate-проверки +/// typeMatchesRule). +/// +/// Поднимаем актуальную (v8) схему, «откатываем» до v7 (убираем tx_type, +/// ставим user_version = 7). Повторное открытие запускает onUpgrade(7 → 8), +/// который должен восстановить колонку. +void main() { + late File file; + + setUp(() { + final dir = Directory.systemTemp.createTempSync('nb_migration_v8_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 7 → 8 добавляет parse_rules.tx_type', () async { + const userId = 'user-1'; + + // 1. Актуальная схема (v8) + FK-цепочка. + final dbV8 = AppDatabase.forTesting(NativeDatabase(file)); + await dbV8.usersDao + .insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест')); + + // 2. «Откатываем» до v7. + await dbV8.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type'); + await dbV8.customStatement('PRAGMA user_version = 7'); + await dbV8.close(); + + // 3. Повторное открытие → onUpgrade(7 → 8). + final dbMigrated = AppDatabase.forTesting(NativeDatabase(file)); + addTearDown(dbMigrated.close); + + // 4. Колонка снова доступна: правило с txType проходит round-trip, + // легаси-правило без txType читается с null. + await dbMigrated.parseRulesDao.insert( + ParseRulesTableCompanion.insert( + id: 'r-typed', + userId: userId, + kind: ParseRuleKind.merchantToCategory, + pattern: 'LENTA', + txType: const Value(TransactionType.expense), + ), + ); + await dbMigrated.parseRulesDao.insert( + ParseRulesTableCompanion.insert( + id: 'r-legacy', + userId: userId, + kind: ParseRuleKind.merchantToCategory, + pattern: 'OZON', + ), + ); + + final typed = await dbMigrated.parseRulesDao.findById('r-typed'); + expect(typed!.txType, TransactionType.expense); + + final legacy = await dbMigrated.parseRulesDao.findById('r-legacy'); + expect(legacy!.txType, isNull); + }); +} diff --git a/test/features/notification_parsing/application/inbox_controller_test.dart b/test/features/notification_parsing/application/inbox_controller_test.dart index abb82a2..d49cce5 100644 --- a/test/features/notification_parsing/application/inbox_controller_test.dart +++ b/test/features/notification_parsing/application/inbox_controller_test.dart @@ -119,6 +119,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository { required MatchMode matchMode, required String pattern, int priority = 0, + TransactionType? txType, String? merchantCanonical, String? categoryId, String? accountId, @@ -127,6 +128,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository { 'kind': kind, 'matchMode': matchMode, 'pattern': pattern, + 'txType': txType, 'merchantCanonical': merchantCanonical, 'categoryId': categoryId, }); @@ -136,6 +138,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository { kind: kind, matchMode: matchMode, pattern: pattern, + txType: txType, merchantCanonical: merchantCanonical, categoryId: categoryId, accountId: accountId, @@ -265,6 +268,8 @@ void main() { expect(rulesRepo.created, hasLength(1)); expect(rulesRepo.created.single['kind'], ParseRuleKind.merchantToCategory); expect(rulesRepo.created.single['categoryId'], 'cat1'); + // Тип операции фиксируется в правиле — gate-проверка typeMatchesRule. + expect(rulesRepo.created.single['txType'], TransactionType.expense); expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA'))); expect(rawRepo.linked, contains(('msg1', 'tx1'))); diff --git a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart index b058a82..6267934 100644 --- a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart +++ b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart @@ -7,6 +7,7 @@ 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/database/converters/enum_converters.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'; @@ -185,12 +186,11 @@ void main() { kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'LENTA', + txType: TransactionType.expense, categoryId: 'cat-1', ); - // AI draft amount score = 60; снижаем строгость, чтобы пройти gate. - await container - .read(parsingSettingsControllerProvider.notifier) - .setStrictness(50); + // Gate-чек-лист: правило есть, сумма 1500 находится в теле, валюта RUB, + // тип совпадает с правилом, счёт — глобальный дефолт (trusted). _activateWorker(container); @@ -211,4 +211,44 @@ void main() { expect(txns.first.accountId, _accountId); expect(txns.first.categoryId, 'cat-1'); }); + + test('allowlist обновляется без перезапуска: добавили банк → парсится', + () async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await _seed(db); + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + isOnlineProvider.overrideWith((ref) => Stream.value(true)), + aiParserProvider.overrideWith((ref) async => + _fakeAiParser(merchantRaw: 'LENTA', amount: 1500)), + ]); + repo = container.read(rawMessagesRepositoryProvider); + await enableAi(container); + _activateWorker(container); + + // 1. Банк ещё не в allowlist → ignored. + final first = await repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: 'Payment of 1500 RUB at LENTA', + receivedAt: DateTime(2026, 5, 31, 12), + ); + expect((await _waitTerminal(repo, first.id)).status, + RawMessageStatus.ignored); + + // 2. Пользователь добавляет банк — контейнер живёт, без «перезапуска». + await container + .read(sourceAppsRepositoryProvider) + .add(userId: _userId, packageName: _bank); + + // 3. Следующее сообщение уже парсится (правила нет → Inbox, не ignored). + final second = await repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: 'Payment of 1500 RUB at LENTA, thanks', + receivedAt: DateTime(2026, 5, 31, 13), + ); + expect( + (await _waitTerminal(repo, second.id)).status, RawMessageStatus.inbox); + }); } diff --git a/test/features/notification_parsing/data/raw_messages_dedup_test.dart b/test/features/notification_parsing/data/raw_messages_dedup_test.dart new file mode 100644 index 0000000..09ca95b --- /dev/null +++ b/test/features/notification_parsing/data/raw_messages_dedup_test.dart @@ -0,0 +1,75 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/dedup.dart'; +import 'package:new_budget/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart'; + +/// Дедупликация вставки `raw_messages` (§15): дубль = тот же dedupHash +/// **и** receivedAt в пределах ±[kDedupWindow]. Тот же текст вне окна — +/// новая реальная операция (подписка, ежедневный кофе), терять её нельзя. + +const _userId = 'u1'; +const _bank = 'ru.sberbankmobile'; +const _body = 'Покупка 100 ₽, Кофейня'; + +void main() { + late AppDatabase db; + late RawMessagesRepositoryImpl repo; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + repo = RawMessagesRepositoryImpl(RawMessagesDao(db)); + }); + + tearDown(() => db.close()); + + Future insertAt(DateTime receivedAt) => repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: _body, + receivedAt: receivedAt, + ); + + test('повторная доставка в пределах окна схлопывается в одну запись', + () async { + final t = DateTime(2026, 6, 10, 12); + final first = await insertAt(t); + final redelivered = await insertAt(t); // ребут/повторный дрейн: то же postTime + final jittered = await insertAt(t.add(const Duration(seconds: 30))); + + expect(redelivered.id, first.id); + expect(jittered.id, first.id); + expect((await repo.recentByUser(_userId, DateTime(2026))).length, 1); + }); + + test('тот же текст вне окна — новое сообщение (регулярный платёж)', + () async { + final t = DateTime(2026, 6, 10, 12); + final today = await insertAt(t); + final justOutside = await insertAt( + t.add(kDedupWindow + const Duration(seconds: 1)), + ); + final tomorrow = await insertAt(t.add(const Duration(days: 1))); + + expect(justOutside.id, isNot(today.id)); + expect(tomorrow.id, isNot(today.id)); + expect((await repo.recentByUser(_userId, DateTime(2026))).length, 3); + }); + + test('findByDedupHash учитывает окно по receivedAt', () async { + final t = DateTime(2026, 6, 10, 12); + await insertAt(t); + final hash = computeDedupHash(_bank, _body); + + expect(await repo.findByDedupHash(_userId, hash, t), isNotNull); + expect( + await repo.findByDedupHash( + _userId, hash, t.add(const Duration(hours: 5))), + isNull, + ); + }); +} diff --git a/test/features/notification_parsing/parser/confidence_scorer_test.dart b/test/features/notification_parsing/parser/confidence_scorer_test.dart index de61d68..b0691b5 100644 --- a/test/features/notification_parsing/parser/confidence_scorer_test.dart +++ b/test/features/notification_parsing/parser/confidence_scorer_test.dart @@ -63,7 +63,6 @@ void main() { ); expect(s.merchant, 100); expect(s.category, 100); - expect(s.otherFieldsMin, 100); }); test('short merchant name is capped at 30', () { diff --git a/test/features/notification_parsing/parser/decision_gate_test.dart b/test/features/notification_parsing/parser/decision_gate_test.dart index 82f2812..91f73b7 100644 --- a/test/features/notification_parsing/parser/decision_gate_test.dart +++ b/test/features/notification_parsing/parser/decision_gate_test.dart @@ -1,124 +1,211 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:new_budget/src/features/notification_parsing/data/parser/confidence_scorer.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart'; import 'package:new_budget/src/features/notification_parsing/data/parser/decision_gate.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; -ParseRule _rule() => ParseRule( +ParseRule _rule({TransactionType? txType = TransactionType.expense}) => + ParseRule( id: 'r1', userId: 'u1', kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'PYATEROCHKA', + txType: txType, categoryId: 'c1', createdAt: DateTime(2026, 1, 1), ); -const _strongScores = FieldScores( - amount: 100, - account: 100, - type: 100, - merchant: 100, - category: 100, +RawMessage _message({String body = 'PYATEROCHKA Покупка 1 240 ₽'}) => + RawMessage( + id: 'm1', + userId: 'u1', + packageName: 'com.bank', + body: body, + receivedAt: DateTime(2026, 1, 2), + dedupHash: 'h1', + status: RawMessageStatus.pending, + createdAt: DateTime(2026, 1, 2), + ); + +ParseDraft _draft({ + int amount = 124000, + TransactionType type = TransactionType.expense, + String currency = 'RUB', + String? accountId = 'a1', +}) => + ParseDraft( + rawMessageId: 'm1', + type: type, + amount: amount, + currency: currency, + merchantRaw: 'PYATEROCHKA', + accountId: accountId, + source: ParseSource.ai, + ); + +const _trusted = AccountResolution( + accountId: 'a1', + score: 100, + trusted: true, + source: AccountSource.bindingCard, +); + +const _untrusted = AccountResolution( + accountId: 'a1', + score: 45, + trusted: false, + source: AccountSource.ambiguous, ); void main() { group('decide', () { - test('rule + sanity + scores ≥ strictness + trusted → autoApply', () { - final d = decide( + test('rule + amount in body + trusted account → autoApply', () { + final r = decide( + autoApplyEnabled: true, merchantRule: _rule(), - sanityPassed: true, - scores: _strongScores, - strictness: 85, - amountMinor: 124000, - accountResolved: true, - accountTrusted: true, + draft: _draft(), + message: _message(), + resolution: _trusted, ); - expect(d, GateDecision.autoApply); + expect(r.decision, GateDecision.autoApply); + expect(r.failed, isEmpty); }); - test('no rule → inbox', () { - final d = decide( + test('toggle off → inbox without evaluating checks', () { + final r = decide( + autoApplyEnabled: false, + merchantRule: _rule(), + draft: _draft(), + message: _message(), + resolution: _trusted, + ); + expect(r.decision, GateDecision.inbox); + expect(r.failed, isEmpty); + }); + + test('no rule → inbox with ruleMatched failed', () { + final r = decide( + autoApplyEnabled: true, merchantRule: null, - sanityPassed: true, - scores: _strongScores, - strictness: 85, - amountMinor: 124000, - accountResolved: true, - accountTrusted: true, + draft: _draft(), + message: _message(), + resolution: _trusted, ); - expect(d, GateDecision.inbox); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.ruleMatched}); }); - test('scores below strictness → inbox', () { - final d = decide( + test('amount not found in body → inbox', () { + final r = decide( + autoApplyEnabled: true, merchantRule: _rule(), - sanityPassed: true, - scores: const FieldScores( - amount: 45, - account: 100, - type: 100, - merchant: 100, - category: 100, - ), - strictness: 85, - amountMinor: 124000, - accountResolved: true, - accountTrusted: true, + draft: _draft(amount: 99900), + message: _message(), + resolution: _trusted, ); - expect(d, GateDecision.inbox); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.amountVerifiedInBody}); + }); + + test('empty currency → inbox', () { + final r = decide( + autoApplyEnabled: true, + merchantRule: _rule(), + draft: _draft(currency: ''), + message: _message(), + resolution: _trusted, + ); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.currencyKnown}); + }); + + test('draft type differs from rule txType → inbox', () { + final r = decide( + autoApplyEnabled: true, + merchantRule: _rule(txType: TransactionType.expense), + draft: _draft(type: TransactionType.income), + message: _message(), + resolution: _trusted, + ); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.typeMatchesRule}); + }); + + test('legacy rule without txType → type check skipped, autoApply', () { + final r = decide( + autoApplyEnabled: true, + merchantRule: _rule(txType: null), + draft: _draft(type: TransactionType.income), + message: _message(), + resolution: _trusted, + ); + expect(r.decision, GateDecision.autoApply); }); test('no account → inbox even with rule', () { - final d = decide( + final r = decide( + autoApplyEnabled: true, merchantRule: _rule(), - sanityPassed: true, - scores: _strongScores, - strictness: 85, - amountMinor: 124000, - accountResolved: false, - accountTrusted: false, + draft: _draft(accountId: null), + message: _message(), + resolution: const AccountResolution( + accountId: null, + score: 15, + trusted: false, + source: AccountSource.none, + ), + ); + expect(r.decision, GateDecision.inbox); + expect( + r.failed, + {AutoApplyCheck.accountResolved, AutoApplyCheck.accountTrusted}, ); - expect(d, GateDecision.inbox); }); test('untrusted account (ambiguous multi-binding) → inbox', () { - final d = decide( + final r = decide( + autoApplyEnabled: true, merchantRule: _rule(), - sanityPassed: true, - scores: _strongScores, - strictness: 85, - amountMinor: 124000, - accountResolved: true, - accountTrusted: false, + draft: _draft(), + message: _message(), + resolution: _untrusted, ); - expect(d, GateDecision.inbox); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.accountTrusted}); }); test('huge amount → inbox even with rule', () { - final d = decide( + const amount = 100001 * 100; + final r = decide( + autoApplyEnabled: true, merchantRule: _rule(), - sanityPassed: true, - scores: _strongScores, - strictness: 85, - amountMinor: 100001 * 100, - accountResolved: true, - accountTrusted: true, + draft: _draft(amount: amount), + message: _message(body: 'PYATEROCHKA Покупка 100 001 ₽'), + resolution: _trusted, ); - expect(d, GateDecision.inbox); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.amountUnderCap}); }); - test('sanity failed → inbox', () { - final d = decide( - merchantRule: _rule(), - sanityPassed: false, - scores: _strongScores, - strictness: 85, - amountMinor: 124000, - accountResolved: true, - accountTrusted: true, + test('collects all failed checks, not just the first', () { + final r = decide( + autoApplyEnabled: true, + merchantRule: null, + draft: _draft(amount: 99900, currency: ''), + message: _message(), + resolution: _untrusted, ); - expect(d, GateDecision.inbox); + expect(r.decision, GateDecision.inbox); + expect(r.failed, { + AutoApplyCheck.ruleMatched, + AutoApplyCheck.amountVerifiedInBody, + AutoApplyCheck.currencyKnown, + AutoApplyCheck.accountTrusted, + }); }); }); }