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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -25,6 +25,19 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Слушатель уведомлений: нативный источник для pipeline парсинга.
|
||||
Разрешение BIND_NOTIFICATION_LISTENER_SERVICE объявляется самим
|
||||
сервисом; пользователь включает доступ в системных настройках. -->
|
||||
<service
|
||||
android:name=".notifications.NotificationIngestService"
|
||||
android:exported="true"
|
||||
android:label="@string/notif_listener_label"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -41,5 +54,11 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Видимость запускаемых приложений для экрана выбора источников
|
||||
уведомлений (Android 11+). QUERY_ALL_PACKAGES не используем. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -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<String>) ?: 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<Map<String, Any?>>(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 схлопываем, сортируем по
|
||||
* имени. Запросы видимости пакетов объявлены в `<queries>` манифеста, поэтому
|
||||
* `QUERY_ALL_PACKAGES` не требуется.
|
||||
*/
|
||||
private fun loadLaunchableApps(context: Context): List<Map<String, Any?>> {
|
||||
val pm = context.packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val resolved = pm.queryIntentActivities(intent, 0)
|
||||
|
||||
val byPackage = LinkedHashMap<String, Map<String, Any?>>()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
+83
@@ -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<String>) {
|
||||
prefs.edit().putStringSet(KEY_MONITORED, packages).apply()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getMonitoredPackages(): Set<String> =
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="notif_listener_label">NewBudget — распознавание уведомлений</string>
|
||||
</resources>
|
||||
@@ -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<AssertionError>())`,
|
||||
работает только в 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, повторный сид), помечены комментарием `// Пиннит текущее
|
||||
поведение: ...` — чтобы при осознанном изменении поведения их меняли, а не «чинили».
|
||||
@@ -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.
|
||||
|
||||
|
||||
+21
-5
@@ -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.",
|
||||
|
||||
+105
-15
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Привязки счетов';
|
||||
|
||||
|
||||
+21
-5
@@ -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": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class InboxController extends _$InboxController {
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
matchMode: matchMode,
|
||||
pattern: pattern,
|
||||
txType: draft.type,
|
||||
merchantCanonical: merchantCanonical,
|
||||
categoryId: categoryId,
|
||||
);
|
||||
|
||||
@@ -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<bool> notificationAccessStatus(Ref ref) =>
|
||||
ref.watch(notificationListenerChannelProvider).isPermissionGranted();
|
||||
|
||||
/// Действия над разрешением: открыть системные настройки и обновить статус.
|
||||
@riverpod
|
||||
class NotificationAccessController extends _$NotificationAccessController {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
/// Открывает системный экран «Доступ к уведомлениям».
|
||||
Future<void> openSettings() =>
|
||||
ref.read(notificationListenerChannelProvider).openSettings();
|
||||
|
||||
/// Перечитывает статус разрешения (например, после возврата из настроек).
|
||||
void refresh() => ref.invalidate(notificationAccessStatusProvider);
|
||||
}
|
||||
@@ -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<void> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -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<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
|
||||
ref.watch(sourceAppsRepositoryProvider).enabledPackages(userId);
|
||||
Stream<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
|
||||
ref.watch(sourceAppsRepositoryProvider).watchEnabledPackages(userId);
|
||||
|
||||
/// Платформенный канал нативного слушателя уведомлений (Android).
|
||||
@Riverpod(keepAlive: true)
|
||||
NotificationListenerChannel notificationListenerChannel(Ref ref) =>
|
||||
NotificationListenerChannel();
|
||||
|
||||
/// Список установленных запускаемых приложений (для экрана выбора источников).
|
||||
/// autoDispose: грузится при открытии пикера, освобождается после закрытия.
|
||||
@riverpod
|
||||
Future<List<InstalledApp>> installedApps(Ref ref) =>
|
||||
ref.watch(notificationListenerChannelProvider).getInstalledApps();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+17
-13
@@ -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<ParsingSettings> 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<void> setStrictness(int value) async {
|
||||
await _set(_kStrictness, '$value');
|
||||
Future<void> 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<void> setAiConsent(bool value) async {
|
||||
|
||||
@@ -82,12 +82,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||
.get();
|
||||
|
||||
/// Поиск по dedupHash для идемпотентной вставки.
|
||||
/// Поиск по dedupHash для идемпотентной вставки (§15): совпавший хэш
|
||||
/// считается дублем только в окне `receivedAt ∈ [from, to]` — иначе
|
||||
/// регулярные одинаковые уведомления (подписка, ежедневный кофе)
|
||||
/// схлопывались бы навсегда.
|
||||
Future<RawMessagesTableData?> 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();
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
|
||||
..orderBy([(t) => OrderingTerm(expression: t.displayName)]))
|
||||
.watch();
|
||||
|
||||
/// Включённые packageName — для allowlist-фильтра.
|
||||
Future<List<String>> enabledPackages(String userId) async {
|
||||
final rows = await (select(sourceAppsTable)
|
||||
..where((t) => t.userId.equals(userId) & t.enabled.equals(true)))
|
||||
.get();
|
||||
return rows.map((r) => r.packageName).toList();
|
||||
}
|
||||
/// Включённые packageName — для allowlist-фильтра. Стрим: эмитит при любом
|
||||
/// изменении таблицы, чтобы allowlist не протухал до перезапуска.
|
||||
Stream<List<String>> 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<SourceAppsTableData?> findByPackageName(
|
||||
String userId,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -13,6 +13,7 @@ extension ParseRuleMapper on ParseRulesTableData {
|
||||
matchCount: matchCount,
|
||||
weight: weight,
|
||||
lastMatchAt: lastMatchAt,
|
||||
txType: txType,
|
||||
merchantCanonical: merchantCanonical,
|
||||
categoryId: categoryId,
|
||||
accountId: accountId,
|
||||
|
||||
@@ -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<bool> isPermissionGranted() async {
|
||||
if (!_supported) return false;
|
||||
final granted = await _method.invokeMethod<bool>('isPermissionGranted');
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
/// Открывает системный экран «Доступ к уведомлениям».
|
||||
Future<void> openSettings() async {
|
||||
if (!_supported) return;
|
||||
await _method.invokeMethod<void>('openSettings');
|
||||
}
|
||||
|
||||
/// Синхронизирует allowlist мониторимых пакетов на нативную сторону.
|
||||
Future<void> setMonitoredPackages(Set<String> packages) async {
|
||||
if (!_supported) return;
|
||||
await _method.invokeMethod<void>(
|
||||
'setMonitoredPackages',
|
||||
packages.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
/// Забирает и очищает нативную очередь пойманных уведомлений.
|
||||
Future<List<NativeNotification>> drainPending() async {
|
||||
if (!_supported) return const [];
|
||||
final raw = await _method.invokeListMethod<dynamic>('drainPending');
|
||||
if (raw == null) return const [];
|
||||
return raw.map((e) {
|
||||
final map = (e as Map).cast<dynamic, dynamic>();
|
||||
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<List<InstalledApp>> getInstalledApps() async {
|
||||
if (!_supported) return const [];
|
||||
final raw = await _method.invokeListMethod<dynamic>('getInstalledApps');
|
||||
if (raw == null) return const [];
|
||||
return raw.map((e) {
|
||||
final map = (e as Map).cast<dynamic, dynamic>();
|
||||
return InstalledApp(
|
||||
packageName: (map['packageName'] as String?) ?? '',
|
||||
appName: (map['appName'] as String?) ?? '',
|
||||
icon: map['icon'] as Uint8List?,
|
||||
);
|
||||
}).where((a) => a.packageName.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Поток сигналов-тиков «появилось новое уведомление, продренируй очередь».
|
||||
Stream<void> ticks() {
|
||||
if (!_supported) return const Stream.empty();
|
||||
return _events.receiveBroadcastStream();
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<AutoApplyCheck> 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 = <AutoApplyCheck>{
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AutoApplyCheck> failedChecks;
|
||||
}
|
||||
|
||||
String encodeDraftBundle(ParseDraft draft, RuleSuggestion? suggestion) {
|
||||
String encodeDraftBundle(
|
||||
ParseDraft draft,
|
||||
RuleSuggestion? suggestion, {
|
||||
Set<AutoApplyCheck> failedChecks = const {},
|
||||
}) {
|
||||
return jsonEncode(<String, dynamic>{
|
||||
'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<AutoApplyCheck> _failedChecksFromJson(Object? raw) {
|
||||
if (raw is! List) return const {};
|
||||
final byName = {for (final c in AutoApplyCheck.values) c.name: c};
|
||||
return raw.whereType<String>().map((n) => byName[n]).nonNulls.toSet();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _draftToJson(ParseDraft d) => {
|
||||
'rawMessageId': d.rawMessageId,
|
||||
'type': d.type.name,
|
||||
|
||||
+4
@@ -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),
|
||||
|
||||
+13
-4
@@ -40,8 +40,15 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
(await _dao.findById(id))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<RawMessage?> findByDedupHash(String userId, String dedupHash) async =>
|
||||
(await _dao.findByDedupHash(userId, dedupHash))?.toDomain();
|
||||
Future<RawMessage?> findByDedupHash(
|
||||
String userId, String dedupHash, DateTime receivedAt) async =>
|
||||
(await _dao.findByDedupHash(
|
||||
userId,
|
||||
dedupHash,
|
||||
receivedAt.subtract(kDedupWindow),
|
||||
receivedAt.add(kDedupWindow),
|
||||
))
|
||||
?.toDomain();
|
||||
|
||||
@override
|
||||
Future<List<RawMessage>> 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(
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Future<Set<String>> enabledPackages(String userId) async =>
|
||||
(await _dao.enabledPackages(userId)).toSet();
|
||||
Stream<Set<String>> watchEnabledPackages(String userId) =>
|
||||
_dao.watchEnabledPackages(userId).map((rows) => rows.toSet());
|
||||
|
||||
@override
|
||||
Future<SourceApp> add({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-2
@@ -24,13 +24,19 @@ abstract interface class RawMessagesRepository {
|
||||
|
||||
Future<RawMessage?> findById(String id);
|
||||
|
||||
Future<RawMessage?> findByDedupHash(String userId, String dedupHash);
|
||||
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
|
||||
/// дедуп-окна вокруг [receivedAt] (§15). Совпадение только по хэшу дублем
|
||||
/// не считается — то же уведомление завтра является новой операцией.
|
||||
Future<RawMessage?> findByDedupHash(
|
||||
String userId, String dedupHash, DateTime receivedAt);
|
||||
|
||||
/// Сообщения за период (для live-превью правил).
|
||||
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
|
||||
|
||||
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
|
||||
/// [dedupHash] уже есть — возвращает существующее, не создавая дубль.
|
||||
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
|
||||
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
|
||||
/// (регулярный платёж) вставляется как новое сообщение.
|
||||
Future<RawMessage> insertIncoming({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ abstract interface class SourceAppsRepository {
|
||||
Stream<List<SourceApp>> watchByUser(String userId);
|
||||
|
||||
/// Множество включённых packageName — для allowlist-фильтра в пайплайне.
|
||||
Future<Set<String>> enabledPackages(String userId);
|
||||
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
|
||||
Stream<Set<String>> watchEnabledPackages(String userId);
|
||||
|
||||
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
|
||||
/// Если строка с таким packageName уже есть — возвращает существующую.
|
||||
|
||||
@@ -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),
|
||||
|
||||
+84
-23
@@ -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<int> 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<int>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _pickFromInstalled(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String userId,
|
||||
) {
|
||||
return showModalBottomSheet<void>(
|
||||
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 <SourceApp>[])
|
||||
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 {
|
||||
|
||||
@@ -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<AutoApplyCheck> failed) {
|
||||
if (failed.isEmpty || failed.contains(AutoApplyCheck.ruleMatched)) {
|
||||
return null;
|
||||
}
|
||||
final reasons =
|
||||
failed.map((c) => gateCheckLabel(context, c)).join(', ');
|
||||
return context.l10n.inboxWhyNotAuto(reasons);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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')));
|
||||
|
||||
@@ -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<bool>.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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<dynamic> 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user