fix tests
This commit is contained in:
@@ -9,7 +9,7 @@ flutter gen-l10n # regenerate localiza
|
||||
flutter analyze
|
||||
flutter run # Android emulator
|
||||
flutter test
|
||||
flutter test test/path/to/file_test.dart -p vm # single test file
|
||||
flutter test test/path/to/file_test.dart # single test file (no -p flag — flutter test rejects it)
|
||||
```
|
||||
|
||||
> Run `build_runner` whenever you touch any `.dart` file that has `@riverpod`, `@DriftDatabase`,
|
||||
@@ -152,6 +152,19 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
|
||||
- Access the container inside a widget test via
|
||||
`ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate
|
||||
notifier state after `pumpWidget`.
|
||||
- **`ParsingWorker` won't drain on `container.read` alone.** Its input
|
||||
`pendingMessagesProvider` is autoDispose and only subscribes to the Drift stream when it
|
||||
has a *direct* listener (in-app that's `HomeScreen`). In a bare `ProviderContainer` add
|
||||
`container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)`
|
||||
alongside reading the worker, or pending messages stay stuck at `pending`.
|
||||
- **Integration tests hitting real OpenRouter** live in
|
||||
`test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run
|
||||
with `flutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-...`
|
||||
(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`.
|
||||
|
||||
## What's left (priority order)
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Registers custom test tags so `flutter test --tags integration` does not warn.
|
||||
#
|
||||
# The `integration` tag marks tests that hit real external services (e.g. the
|
||||
# OpenRouter API) and consume API tokens. They are excluded from a default
|
||||
# `flutter test` run and are opt-in via `--tags integration`.
|
||||
tags:
|
||||
integration:
|
||||
# Network-bound; allow extra time per test.
|
||||
timeout: 2x
|
||||
@@ -0,0 +1,321 @@
|
||||
# План: анализ привычек трат (две шкалы оценки транзакций)
|
||||
|
||||
## Контекст
|
||||
|
||||
Добавляем механику «трекера привычек»: каждую транзакцию можно оценить по двум независимым
|
||||
шкалам, а затем анализировать структуру трат по этим шкалам на отдельном подэкране аналитики.
|
||||
|
||||
Шкалы (обе **опциональные**, по умолчанию «не отмечено»):
|
||||
|
||||
1. **Обязательность** (`obligation`): `required` (Обязательно), `optional` (Не обязательно),
|
||||
`unnecessary` (Не нужно).
|
||||
2. **Импульсивность** (`impulse`): `impulsive` (Импульсивно), `considered` (Обдуманно).
|
||||
|
||||
Вся фича прячется за тумблером в настройках профиля
|
||||
(`habitTrackingEnabled`). Когда выключено — БД-поля остаются, но UI (ярлыки в строках,
|
||||
селекторы в форме, подэкран аналитики) не показывается. Это значит: **миграция и доменные
|
||||
поля делаются всегда**, а презентация — условная.
|
||||
|
||||
Соответствие архитектуре (см. CLAUDE.md): `data` (Drift) → `domain` (entity/enum) →
|
||||
`application` (провайдеры) → `presentation` (виджеты). Цвета — только через тему, без хардкода.
|
||||
|
||||
---
|
||||
|
||||
## Часть 0. Данные и доменка (фундамент, нужен всегда)
|
||||
|
||||
### 0.1 Enum'ы шкал
|
||||
[enum_converters.dart](../lib/src/core/database/converters/enum_converters.dart) — добавить рядом
|
||||
с существующими enum'ами + **nullable** TypeConverter'ы (поля опциональны):
|
||||
|
||||
```dart
|
||||
enum SpendingObligation { required, optional, unnecessary }
|
||||
|
||||
class SpendingObligationConverter extends TypeConverter<SpendingObligation, String> {
|
||||
const SpendingObligationConverter();
|
||||
@override
|
||||
SpendingObligation fromSql(String fromDb) =>
|
||||
SpendingObligation.values.firstWhere((e) => e.name == fromDb);
|
||||
@override
|
||||
String toSql(SpendingObligation value) => value.name;
|
||||
}
|
||||
|
||||
enum SpendingImpulse { impulsive, considered }
|
||||
// + SpendingImpulseConverter по тому же шаблону
|
||||
```
|
||||
|
||||
> Drift применяет `.map(converter)` к **не**-nullable колонке; для nullable-колонки конвертер
|
||||
> вызывается только для не-null значений (`.nullable()` ставится после `.map(...)`).
|
||||
|
||||
### 0.2 Колонки транзакции
|
||||
[transactions_table.dart](../lib/src/core/database/tables/transactions_table.dart) — после
|
||||
`extraInfo`, до полей парсинга:
|
||||
|
||||
```dart
|
||||
/// Шкала «обязательность» (habit-tracking). null = не отмечено.
|
||||
TextColumn get obligation =>
|
||||
text().map(const SpendingObligationConverter()).nullable()();
|
||||
|
||||
/// Шкала «импульсивность» (habit-tracking). null = не отмечено.
|
||||
TextColumn get impulse =>
|
||||
text().map(const SpendingImpulseConverter()).nullable()();
|
||||
```
|
||||
|
||||
### 0.3 Тумблер в настройках
|
||||
[settings_table.dart](../lib/src/core/database/tables/settings_table.dart):
|
||||
|
||||
```dart
|
||||
BoolColumn get habitTrackingEnabled =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
```
|
||||
|
||||
### 0.4 Миграция
|
||||
[app_database.dart](../lib/src/core/database/app_database.dart): `schemaVersion` **5 → 6**,
|
||||
новый блок в `onUpgrade`:
|
||||
|
||||
```dart
|
||||
if (from < 6) {
|
||||
await m.addColumn(transactionsTable, transactionsTable.obligation);
|
||||
await m.addColumn(transactionsTable, transactionsTable.impulse);
|
||||
await m.addColumn(settingsTable, settingsTable.habitTrackingEnabled);
|
||||
}
|
||||
```
|
||||
|
||||
### 0.5 Доменные сущности + мапперы + черновик
|
||||
- [transaction.dart](../lib/src/features/transactions/domain/entities/transaction.dart) (freezed):
|
||||
добавить `SpendingObligation? obligation` и `SpendingImpulse? impulse`.
|
||||
- [transaction_mapper.dart](../lib/src/features/transactions/data/mappers/transaction_mapper.dart):
|
||||
пробросить `obligation: obligation, impulse: impulse`.
|
||||
- [transaction_repository.dart](../lib/src/features/transactions/domain/repositories/transaction_repository.dart)
|
||||
+ [transaction_repository_impl.dart](../lib/src/features/transactions/data/repositories/transaction_repository_impl.dart):
|
||||
добавить параметры `obligation`/`impulse` в `create(...)` и прокинуть в companion
|
||||
(`update(Transaction)` уже принимает сущность целиком — нужно лишь добавить поля в companion-мапинг).
|
||||
- [transactions_controller.dart](../lib/src/features/transactions/application/transactions_controller.dart):
|
||||
добавить `obligation`/`impulse` в сигнатуру `createTransaction(...)` и проброс в репозиторий.
|
||||
- [transaction_draft.dart](../lib/src/features/transactions/presentation/state/transaction_draft.dart):
|
||||
два новых nullable-поля + сеттеры `setObligation`/`setImpulse` (использовать тот же
|
||||
`_sentinel`-паттерн в `copyWith`, чтобы можно было сбрасывать в null).
|
||||
- [settings.dart](../lib/src/features/settings/domain/entities/settings.dart) +
|
||||
[settings_mapper.dart](../lib/src/features/settings/data/mappers/settings_mapper.dart):
|
||||
добавить `bool habitTrackingEnabled`.
|
||||
- Settings repo/controller: метод `setHabitTrackingEnabled(userId, bool)` по образцу
|
||||
`updateThemeMode` ([settings_repository_impl.dart](../lib/src/features/settings/data/repositories/settings_repository_impl.dart),
|
||||
[settings_controller.dart](../lib/src/features/settings/application/settings_controller.dart));
|
||||
не забыть `habitTrackingEnabled` в `_toCompanion`, `_defaultSettings` (= `false`) и `ensureDefaults`.
|
||||
|
||||
### 0.6 Code-gen
|
||||
После правок: `dart run build_runner build --delete-conflicting-outputs`
|
||||
(затрагивает `app_database.g.dart`, `transaction.freezed.dart`, `settings.freezed.dart`,
|
||||
`transaction_draft.g.dart`). Затем `flutter analyze`.
|
||||
|
||||
---
|
||||
|
||||
## Часть 1. Ярлыки в строке транзакции (presentation)
|
||||
|
||||
### 1.1 Цвета ярлыков
|
||||
Семантические цвета шкал (тёмно-зелёный/светло-зелёный и т.д.) **не относятся к брендовой
|
||||
палитре** `AppPalette`, но хардкод в виджетах запрещён. Решение: отдельный
|
||||
`ThemeExtension` рядом с палитрой.
|
||||
|
||||
Новый файл `lib/src/app/theme/habit_chip_colors.dart` — `HabitChipColors extends
|
||||
ThemeExtension<HabitChipColors>` с парами фон/текст под каждое значение + светлый/тёмный
|
||||
варианты, по образцу [app_colors.dart](../lib/src/app/theme/app_colors.dart) (`copyWith`/`lerp`/
|
||||
`extension ... on BuildContext`). Зарегистрировать в `ThemeData.extensions` в
|
||||
[app_theme.dart](../lib/src/app/theme/app_theme.dart) рядом с `AppPalette`.
|
||||
|
||||
Токены (тёмная тема как база; для светлой подобрать читаемые аналоги):
|
||||
|
||||
| Значение | Фон | Текст/иконка |
|
||||
|---|---|---|
|
||||
| `required` (Обязательно) | тёмно-зелёный | светло-зелёный |
|
||||
| `optional` (Не обязательно) | тёмно-горчичный / коричнево-оранжевый | песочный |
|
||||
| `unnecessary` (Не нужно) | тёмно-бордовый | бледно-розовый |
|
||||
| `impulsive` (⚡ Импульс) | тёмно-коричневый / полупрозрачный оранжевый | светло-оранжевый |
|
||||
|
||||
`considered` (Обдуманно) ярлыком **не** показывается (чтобы не перегружать список).
|
||||
|
||||
### 1.2 Виджет ярлыков
|
||||
Новый `lib/src/features/home/presentation/widgets/habit_chips.dart`:
|
||||
|
||||
- `HabitChips({required Transaction tx})` → `Row` с выравниванием по левому краю.
|
||||
- Каждый ярлык — «пилюля»: `Container` с `BorderRadius.circular(99)`,
|
||||
`padding: EdgeInsets.symmetric(horizontal: 6, vertical: 1)`, `fontSize: 11`
|
||||
(как у времени в [tx_row.dart](../lib/src/features/home/presentation/widgets/tx_row.dart)),
|
||||
`fontWeight: FontWeight.w600`.
|
||||
- Порядок: сначала ярлык обязательности (если задан), затем «⚡ Импульс» (только при
|
||||
`impulse == impulsive`); между ними `SizedBox(width: 6)`.
|
||||
- «⚡ Импульс» = `Icon(Icons.bolt, size: 11)` слева + текст.
|
||||
- Пустое состояние (обе шкалы null) → текст «не отмечено», `fontStyle: italic`,
|
||||
без фона, цвет `p.ink2`.
|
||||
- Локализация всех подписей через `context.l10n` (см. §3).
|
||||
|
||||
### 1.3 Интеграция в строку
|
||||
В [tx_row.dart](../lib/src/features/home/presentation/widgets/tx_row.dart) фича-флаг приходит
|
||||
параметром (виджет — `StatelessWidget`, без `ref`): добавить
|
||||
`final bool habitTrackingEnabled;` в конструктор `TxRow`.
|
||||
|
||||
Логика подвала строки (сейчас `subtitle = '${cat?.name} · ${time}'` на 3-й строке, плюс
|
||||
опциональная строка `extraInfo`):
|
||||
|
||||
- **Флаг выключен** → поведение как сейчас.
|
||||
- **Флаг включён** → блок `extraInfo` **заменяется** строкой привычек: время + `·` + ярлыки.
|
||||
То есть нижняя строка = `Row[ Text(time), Text(' · '), HabitChips(tx) ]` с выравниванием
|
||||
по левому краю. (Категория остаётся в `subtitle`-строке как сейчас, либо переносится — см.
|
||||
«Открытый вопрос» ниже; базовый вариант: ярлыки идут вместо `extraInfo`, как просил юзер
|
||||
«Вместо доп. информации».)
|
||||
|
||||
Источник флага: тот, кто строит `TxRow`
|
||||
([transactions_section.dart](../lib/src/features/home/presentation/widgets/transactions_section.dart)
|
||||
и аналитический список §2) читает
|
||||
`ref.watch(settingsControllerProvider(userId)).value?.habitTrackingEnabled ?? false`
|
||||
и прокидывает в `TxRow`.
|
||||
|
||||
### 1.4 Селекторы в форме транзакции
|
||||
[transaction_form_screen.dart](../lib/src/features/transactions/presentation/screens/transaction_form_screen.dart):
|
||||
при включённом флаге показать два селектора (под существующими полями):
|
||||
|
||||
- **Обязательность** — три chip'а в ряд (по образцу `type_segmented.dart` /
|
||||
фильтр-chip'ов), повторное нажатие на активный сбрасывает в null.
|
||||
- **Импульсивность** — segmented на два значения (Импульсивно/Обдуманно), также сбрасываемый.
|
||||
|
||||
Связать с `TransactionDraftController.setObligation/setImpulse`; прокинуть значения в
|
||||
`createTransaction(...)` / `updateTransaction(...)` при сохранении. При hydrate из существующей
|
||||
транзакции (режим редактирования) — заполнить из сущности.
|
||||
|
||||
---
|
||||
|
||||
## Часть 2. Подэкран «Анализ привычек» в аналитике
|
||||
|
||||
### 2.1 Аналитика как хаб
|
||||
Сейчас [analytics_screen.dart](../lib/src/features/analytics/presentation/screens/analytics_screen.dart) —
|
||||
заглушка. Превращаем в список подэкранов (на будущее их несколько); первый —
|
||||
**«Анализ привычек»** (показывать пункт только при `habitTrackingEnabled`).
|
||||
|
||||
Навигация:
|
||||
- [app_routes.dart](../lib/src/app/router/app_routes.dart): `static const habitAnalysis = '/analytics/habits';`
|
||||
- [app_router.dart](../lib/src/app/router/app_router.dart): `GoRoute` внутри ветки analytics
|
||||
(или push поверх) → новый экран.
|
||||
|
||||
Новый файл `lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart`
|
||||
(`ConsumerWidget`).
|
||||
|
||||
### 2.2 Шапка + выбор месяца
|
||||
- Крупный заголовок «Транзакции» слева (белый/`p.ink`).
|
||||
- Селектор месяца справа/над заголовком: «Май 2026» + стрелки `‹ ›`, переиспользуя
|
||||
существующий `selectedMonthProvider` (`.previous()` / `.next()`) из
|
||||
[selected_category_filter.dart](../lib/src/features/home/presentation/state/selected_category_filter.dart)
|
||||
и формат `DateFormat('LLLL yyyy', locale)`. Цвет текста — `p.ink`/`p.ink2`.
|
||||
|
||||
> Месяц общий с главным экраном (тот же провайдер) — это ок и даже желательно
|
||||
> (консистентный период). Если нужна независимость — завести отдельный
|
||||
> `habitSelectedMonthProvider`; **решить до реализации** (см. открытые вопросы).
|
||||
|
||||
### 2.3 Провайдеры агрегатов
|
||||
Новый файл `lib/src/features/analytics/application/habit_analysis_providers.dart`
|
||||
(`@riverpod`), по образцу [month_summary.dart](../lib/src/features/home/presentation/month_summary.dart):
|
||||
|
||||
- Состояние фильтров (autoDispose `@riverpod class`):
|
||||
- `HabitImpulseFilter` → `SpendingImpulse?` + спец-значение «Все» (null = все).
|
||||
- `HabitObligationFilter` → `Set<SpendingObligation>` (мульти-выбор chip'ов; пусто = все).
|
||||
- `habitMonthTransactions(userId)` — транзакции за `selectedMonth`, **только расходы**
|
||||
(`type == expense`), без учёта фильтров (нужны для подсчёта сумм в каждой секции).
|
||||
- `habitSumByImpulse(userId)` → `Map<SpendingImpulse?, int>` (включая ключ null = «без оценки»;
|
||||
для секции «Все» — общая сумма).
|
||||
- `habitSumByObligation(userId)` → `Map<SpendingObligation?, int>`.
|
||||
- `habitFilteredTransactions(userId)` — применяет оба фильтра к `habitMonthTransactions`
|
||||
(для сводки и списка).
|
||||
|
||||
Суммы — в минорных единицах; форматирование через `MoneyText`
|
||||
([money_text.dart](../lib/src/features/home/presentation/widgets/money_text.dart)).
|
||||
|
||||
### 2.4 Блок фильтров (с суммами)
|
||||
Над контролами — подзаголовки мелким шрифтом, ALL CAPS, цвет `p.ink2`:
|
||||
«ИМПУЛЬСИВНОСТЬ», «ОБЯЗАТЕЛЬНОСТЬ».
|
||||
|
||||
**Импульсивность — segmented control** (3 равные секции, контент в 2 строки, текст по центру):
|
||||
| Секция | Строка 1 | Строка 2 |
|
||||
|---|---|---|
|
||||
| Все | Все | сумма |
|
||||
| Обдуманные | Обдуманные | сумма |
|
||||
| Импульс | ⚡ Импульс | сумма |
|
||||
|
||||
Активный сегмент — фон `p.ink2`/тёмно-серый + белый текст; неактивные сливаются с фоном
|
||||
(паттерн `type_segmented.dart`).
|
||||
|
||||
**Обязательность — набор chip'ов** (3 кнопки в ряд, тонкая обводка `p.line`, цветная
|
||||
точка-индикатор слева, контент в 2 строки):
|
||||
| Chip | Точка | Строка 1 | Строка 2 |
|
||||
|---|---|---|---|
|
||||
| Обязательно | 🟢 | Обязательно | сумма |
|
||||
| Можно без | 🟡 | Можно без | сумма |
|
||||
| Не нужно | 🔴 | Не нужно | сумма |
|
||||
|
||||
Цвета точек — из `HabitChipColors` (§1.1). Нажатие тоглит значение в `HabitObligationFilter`.
|
||||
|
||||
> Подписи фильтра отличаются от ярлыков строки: здесь «Можно без» вместо «Не обязательно» —
|
||||
> это отдельные l10n-ключи (§3).
|
||||
|
||||
### 2.5 Сводка + список
|
||||
- Сводная строка под фильтрами: слева количество («10 операций» — переиспользовать
|
||||
`l10n.transactionCount(n)`), справа крупная сумма («−40 592 ₽», `MoneyText` крупным шрифтом)
|
||||
по `habitFilteredTransactions`.
|
||||
- Ниже — скроллируемый список за выбранный месяц, отфильтрованный, **дизайн идентичен
|
||||
главному экрану**: переиспользовать `TxRow` (с `habitTrackingEnabled: true`, чтобы ярлыки
|
||||
были видны) и группировку по дням (`DayHeader`).
|
||||
|
||||
---
|
||||
|
||||
## Часть 3. Локализация
|
||||
[app_en.arb](../lib/l10n/app_en.arb) / [app_ru.arb](../lib/l10n/app_ru.arb) → `flutter gen-l10n`.
|
||||
Новые ключи (RU значения):
|
||||
|
||||
| Ключ | RU |
|
||||
|---|---|
|
||||
| `habitTrackingTile` | «Анализ привычек трат» (тумблер в профиле) |
|
||||
| `habitObligationRequired` | «Обязательно» |
|
||||
| `habitObligationOptional` | «Не обязательно» |
|
||||
| `habitObligationUnnecessary` | «Не нужно» |
|
||||
| `habitObligationOptionalShort` | «Можно без» (для фильтра аналитики) |
|
||||
| `habitImpulseImpulsive` | «Импульс» |
|
||||
| `habitImpulseConsidered` | «Обдуманные» |
|
||||
| `habitNotMarked` | «не отмечено» |
|
||||
| `habitFilterAll` | «Все» |
|
||||
| `habitScaleImpulseCaps` | «ИМПУЛЬСИВНОСТЬ» |
|
||||
| `habitScaleObligationCaps` | «ОБЯЗАТЕЛЬНОСТЬ» |
|
||||
| `habitAnalysisTitle` | «Транзакции» (заголовок подэкрана) |
|
||||
| `habitAnalysisTile` | «Анализ привычек» (пункт в хабе аналитики) |
|
||||
|
||||
---
|
||||
|
||||
## Часть 4. Тесты (см. соглашения в CLAUDE.md → Testing)
|
||||
- **Миграция v5→v6**: тест на `AppDatabase.forTesting` — apply миграции, проверить наличие
|
||||
колонок и дефолтов.
|
||||
- **Repo/controller**: `create`/`update` с `obligation`/`impulse` (round-trip через FakeRepo и
|
||||
через реальную in-memory БД).
|
||||
- **Агрегаты**: `habitSumByObligation`/`habitSumByImpulse`/`habitFilteredTransactions` —
|
||||
`ProviderContainer` с override стрима транзакций, проверить суммы и фильтрацию (вкл. ключ null).
|
||||
- **Widget**: `HabitChips` — рендер пилюль по значениям, пустое состояние «не отмечено»;
|
||||
`TxRow` с `habitTrackingEnabled` true/false (ярлыки vs `extraInfo`).
|
||||
- `import 'package:drift/drift.dart' hide isNull, isNotNull;` в БД-тестах.
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
1. **Часть 0** целиком (data/domain/migration) + `build_runner` + `flutter analyze` — фундамент.
|
||||
2. Тумблер в профиле ([profile_screen.dart](../lib/src/features/profile/presentation/screens/profile_screen.dart),
|
||||
`_Row` со `Switch`, как у тёмной темы) → `settingsController.setHabitTrackingEnabled`.
|
||||
3. `HabitChipColors` + `HabitChips` + интеграция в `TxRow` и `transactions_section`.
|
||||
4. Селекторы в форме транзакции.
|
||||
5. Подэкран «Анализ привычек» (провайдеры → шапка → фильтры → сводка → список).
|
||||
6. Локализация (по ходу 2–5).
|
||||
7. Тесты.
|
||||
|
||||
## Открытые вопросы (решить до реализации)
|
||||
- **Подвал строки**: ярлыки *заменяют* `extraInfo` (как написано в ТЗ), но что с категорией +
|
||||
временем? Базовый план: ярлыки идут вместо строки `extraInfo`, строка `категория · время`
|
||||
остаётся. Альтернатива: время уходит в строку ярлыков (`время · ярлыки`), категория — в
|
||||
основную. Уточнить визуал.
|
||||
- **Месяц в аналитике**: общий `selectedMonthProvider` с главным экраном или отдельный.
|
||||
- **Палитра habit-цветов**: точные hex'ы для светлой темы (в ТЗ описаны «тёмные» фоны —
|
||||
они под тёмную тему; для светлой нужны читаемые аналоги).
|
||||
- **`required` как имя enum-значения** — зарезервированное слово Dart можно использовать как
|
||||
имя поля enum, но если возникнут конфликты, переименовать в `mandatory`.
|
||||
+40
-3
@@ -98,10 +98,10 @@
|
||||
"txDateTimeLabel": "Date & time",
|
||||
"txBalanceAfter": "Balance after",
|
||||
"txTransferAfter": "After transfer",
|
||||
"txNoteLabel": "Note",
|
||||
"txNoteHint": "e.g. Groceries for the week…",
|
||||
"txNoteLabel": "Merchant",
|
||||
"txNoteHint": "e.g. Amazon…",
|
||||
"txExtraInfoLabel": "Additional info",
|
||||
"txExtraInfoHint": "e.g. receipt #, URL, reference…",
|
||||
"txExtraInfoHint": "e.g. Groceries for the week…",
|
||||
"txSaveButton": "Add transaction",
|
||||
"txSaveEditButton": "Save changes",
|
||||
"txTransferButton": "Transfer {amount}",
|
||||
@@ -236,6 +236,43 @@
|
||||
"parsingStatusPendingAi": "Waiting for AI",
|
||||
"parsingStatusParsedPartial": "Partial",
|
||||
|
||||
"parsingLogOnline": "Online",
|
||||
"parsingLogOffline": "Offline",
|
||||
"parsingLogFilterWaiting": "Waiting",
|
||||
|
||||
"parsingDetailConfidence": "Confidence",
|
||||
"parsingDetailPipeline": "Pipeline",
|
||||
"parsingDetailMessage": "Message",
|
||||
"parsingDetailDraft": "Draft",
|
||||
"parsingDetailRule": "Rule suggestion",
|
||||
"parsingDetailMeta": "Meta",
|
||||
"parsingDetailAttempt": "Attempt {count}/{max}",
|
||||
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
|
||||
"parsingDetailSource": "Source",
|
||||
"parsingDetailAmountScore": "Amount",
|
||||
"parsingDetailAccountScore": "Account",
|
||||
"parsingDetailTypeScore": "Type",
|
||||
"parsingDetailMerchantScore": "Merchant",
|
||||
"parsingDetailCategoryScore": "Category",
|
||||
"parsingDetailTitle": "Title",
|
||||
"parsingDetailBody": "Body",
|
||||
"parsingDetailCard": "Card",
|
||||
"parsingDetailCurrency": "Currency",
|
||||
"parsingDetailKind": "Kind",
|
||||
"parsingDetailDate": "Date",
|
||||
"parsingDetailMerchantRaw": "Merchant (raw)",
|
||||
"parsingDetailMerchantCanonical": "Merchant (canonical)",
|
||||
"parsingDetailCounterparty": "Counterparty",
|
||||
"parsingDetailCategorySuggestion": "Category hint",
|
||||
"parsingDetailAccountId": "Account ID",
|
||||
"parsingDetailCategoryId": "Category ID",
|
||||
"parsingDetailTransferTo": "Transfer to",
|
||||
"parsingDetailRuleCategory": "Category",
|
||||
"parsingDetailTransactionId": "Transaction ID",
|
||||
"parsingDetailDedupHash": "Dedup hash",
|
||||
"parsingDetailReceivedAt": "Received at",
|
||||
"parsingDetailCreatedAt": "Created at",
|
||||
|
||||
"inboxTitle": "From notifications",
|
||||
"inboxSubtitle": "A rule is learned on the first tap: similar messages will be confirmed automatically.",
|
||||
"inboxEmpty": "Inbox is empty. New notifications will appear here.",
|
||||
|
||||
@@ -389,13 +389,13 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @txNoteLabel.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Заметка'**
|
||||
/// **'Мерчант'**
|
||||
String get txNoteLabel;
|
||||
|
||||
/// No description provided for @txNoteHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Например, продукты на неделю…'**
|
||||
/// **'Например, Пятерочка…'**
|
||||
String get txNoteHint;
|
||||
|
||||
/// No description provided for @txExtraInfoLabel.
|
||||
@@ -407,7 +407,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @txExtraInfoHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Например, номер чека, ссылка…'**
|
||||
/// **'Например, продукты на неделю…'**
|
||||
String get txExtraInfoHint;
|
||||
|
||||
/// No description provided for @txSaveButton.
|
||||
@@ -1070,6 +1070,210 @@ abstract class AppLocalizations {
|
||||
/// **'Частично'**
|
||||
String get parsingStatusParsedPartial;
|
||||
|
||||
/// No description provided for @parsingLogOnline.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Онлайн'**
|
||||
String get parsingLogOnline;
|
||||
|
||||
/// No description provided for @parsingLogOffline.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Офлайн'**
|
||||
String get parsingLogOffline;
|
||||
|
||||
/// No description provided for @parsingLogFilterWaiting.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ждёт сети'**
|
||||
String get parsingLogFilterWaiting;
|
||||
|
||||
/// No description provided for @parsingDetailConfidence.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Уверенность'**
|
||||
String get parsingDetailConfidence;
|
||||
|
||||
/// No description provided for @parsingDetailPipeline.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Pipeline'**
|
||||
String get parsingDetailPipeline;
|
||||
|
||||
/// No description provided for @parsingDetailMessage.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Сообщение'**
|
||||
String get parsingDetailMessage;
|
||||
|
||||
/// No description provided for @parsingDetailDraft.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Черновик'**
|
||||
String get parsingDetailDraft;
|
||||
|
||||
/// No description provided for @parsingDetailRule.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Предложение правила'**
|
||||
String get parsingDetailRule;
|
||||
|
||||
/// No description provided for @parsingDetailMeta.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Мета'**
|
||||
String get parsingDetailMeta;
|
||||
|
||||
/// No description provided for @parsingDetailAttempt.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Попытка {count}/{max}'**
|
||||
String parsingDetailAttempt(int count, int max);
|
||||
|
||||
/// No description provided for @parsingDetailSource.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Источник'**
|
||||
String get parsingDetailSource;
|
||||
|
||||
/// No description provided for @parsingDetailAmountScore.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Сумма'**
|
||||
String get parsingDetailAmountScore;
|
||||
|
||||
/// No description provided for @parsingDetailAccountScore.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Счёт'**
|
||||
String get parsingDetailAccountScore;
|
||||
|
||||
/// No description provided for @parsingDetailTypeScore.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Тип'**
|
||||
String get parsingDetailTypeScore;
|
||||
|
||||
/// No description provided for @parsingDetailMerchantScore.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Мерчант'**
|
||||
String get parsingDetailMerchantScore;
|
||||
|
||||
/// No description provided for @parsingDetailCategoryScore.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Категория'**
|
||||
String get parsingDetailCategoryScore;
|
||||
|
||||
/// No description provided for @parsingDetailTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Заголовок'**
|
||||
String get parsingDetailTitle;
|
||||
|
||||
/// No description provided for @parsingDetailBody.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Текст'**
|
||||
String get parsingDetailBody;
|
||||
|
||||
/// No description provided for @parsingDetailCard.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Карта'**
|
||||
String get parsingDetailCard;
|
||||
|
||||
/// No description provided for @parsingDetailCurrency.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Валюта'**
|
||||
String get parsingDetailCurrency;
|
||||
|
||||
/// No description provided for @parsingDetailKind.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Вид'**
|
||||
String get parsingDetailKind;
|
||||
|
||||
/// No description provided for @parsingDetailDate.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Дата'**
|
||||
String get parsingDetailDate;
|
||||
|
||||
/// No description provided for @parsingDetailMerchantRaw.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Мерчант (сырой)'**
|
||||
String get parsingDetailMerchantRaw;
|
||||
|
||||
/// No description provided for @parsingDetailMerchantCanonical.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Мерчант (норм.)'**
|
||||
String get parsingDetailMerchantCanonical;
|
||||
|
||||
/// No description provided for @parsingDetailCounterparty.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Контрагент'**
|
||||
String get parsingDetailCounterparty;
|
||||
|
||||
/// No description provided for @parsingDetailCategorySuggestion.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Подсказка категории'**
|
||||
String get parsingDetailCategorySuggestion;
|
||||
|
||||
/// No description provided for @parsingDetailAccountId.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'ID счёта'**
|
||||
String get parsingDetailAccountId;
|
||||
|
||||
/// No description provided for @parsingDetailCategoryId.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'ID категории'**
|
||||
String get parsingDetailCategoryId;
|
||||
|
||||
/// No description provided for @parsingDetailTransferTo.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Перевод на'**
|
||||
String get parsingDetailTransferTo;
|
||||
|
||||
/// No description provided for @parsingDetailRuleCategory.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Категория'**
|
||||
String get parsingDetailRuleCategory;
|
||||
|
||||
/// No description provided for @parsingDetailTransactionId.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'ID транзакции'**
|
||||
String get parsingDetailTransactionId;
|
||||
|
||||
/// No description provided for @parsingDetailDedupHash.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Dedup-хеш'**
|
||||
String get parsingDetailDedupHash;
|
||||
|
||||
/// No description provided for @parsingDetailReceivedAt.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Получено'**
|
||||
String get parsingDetailReceivedAt;
|
||||
|
||||
/// No description provided for @parsingDetailCreatedAt.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Создано'**
|
||||
String get parsingDetailCreatedAt;
|
||||
|
||||
/// No description provided for @inboxTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
|
||||
@@ -182,16 +182,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get txTransferAfter => 'After transfer';
|
||||
|
||||
@override
|
||||
String get txNoteLabel => 'Note';
|
||||
String get txNoteLabel => 'Merchant';
|
||||
|
||||
@override
|
||||
String get txNoteHint => 'e.g. Groceries for the week…';
|
||||
String get txNoteHint => 'e.g. Amazon…';
|
||||
|
||||
@override
|
||||
String get txExtraInfoLabel => 'Additional info';
|
||||
|
||||
@override
|
||||
String get txExtraInfoHint => 'e.g. receipt #, URL, reference…';
|
||||
String get txExtraInfoHint => 'e.g. Groceries for the week…';
|
||||
|
||||
@override
|
||||
String get txSaveButton => 'Add transaction';
|
||||
@@ -554,6 +554,110 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get parsingStatusParsedPartial => 'Partial';
|
||||
|
||||
@override
|
||||
String get parsingLogOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get parsingLogOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterWaiting => 'Waiting';
|
||||
|
||||
@override
|
||||
String get parsingDetailConfidence => 'Confidence';
|
||||
|
||||
@override
|
||||
String get parsingDetailPipeline => 'Pipeline';
|
||||
|
||||
@override
|
||||
String get parsingDetailMessage => 'Message';
|
||||
|
||||
@override
|
||||
String get parsingDetailDraft => 'Draft';
|
||||
|
||||
@override
|
||||
String get parsingDetailRule => 'Rule suggestion';
|
||||
|
||||
@override
|
||||
String get parsingDetailMeta => 'Meta';
|
||||
|
||||
@override
|
||||
String parsingDetailAttempt(int count, int max) {
|
||||
return 'Attempt $count/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get parsingDetailSource => 'Source';
|
||||
|
||||
@override
|
||||
String get parsingDetailAmountScore => 'Amount';
|
||||
|
||||
@override
|
||||
String get parsingDetailAccountScore => 'Account';
|
||||
|
||||
@override
|
||||
String get parsingDetailTypeScore => 'Type';
|
||||
|
||||
@override
|
||||
String get parsingDetailMerchantScore => 'Merchant';
|
||||
|
||||
@override
|
||||
String get parsingDetailCategoryScore => 'Category';
|
||||
|
||||
@override
|
||||
String get parsingDetailTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get parsingDetailBody => 'Body';
|
||||
|
||||
@override
|
||||
String get parsingDetailCard => 'Card';
|
||||
|
||||
@override
|
||||
String get parsingDetailCurrency => 'Currency';
|
||||
|
||||
@override
|
||||
String get parsingDetailKind => 'Kind';
|
||||
|
||||
@override
|
||||
String get parsingDetailDate => 'Date';
|
||||
|
||||
@override
|
||||
String get parsingDetailMerchantRaw => 'Merchant (raw)';
|
||||
|
||||
@override
|
||||
String get parsingDetailMerchantCanonical => 'Merchant (canonical)';
|
||||
|
||||
@override
|
||||
String get parsingDetailCounterparty => 'Counterparty';
|
||||
|
||||
@override
|
||||
String get parsingDetailCategorySuggestion => 'Category hint';
|
||||
|
||||
@override
|
||||
String get parsingDetailAccountId => 'Account ID';
|
||||
|
||||
@override
|
||||
String get parsingDetailCategoryId => 'Category ID';
|
||||
|
||||
@override
|
||||
String get parsingDetailTransferTo => 'Transfer to';
|
||||
|
||||
@override
|
||||
String get parsingDetailRuleCategory => 'Category';
|
||||
|
||||
@override
|
||||
String get parsingDetailTransactionId => 'Transaction ID';
|
||||
|
||||
@override
|
||||
String get parsingDetailDedupHash => 'Dedup hash';
|
||||
|
||||
@override
|
||||
String get parsingDetailReceivedAt => 'Received at';
|
||||
|
||||
@override
|
||||
String get parsingDetailCreatedAt => 'Created at';
|
||||
|
||||
@override
|
||||
String get inboxTitle => 'From notifications';
|
||||
|
||||
|
||||
@@ -188,16 +188,16 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get txTransferAfter => 'После перевода';
|
||||
|
||||
@override
|
||||
String get txNoteLabel => 'Заметка';
|
||||
String get txNoteLabel => 'Мерчант';
|
||||
|
||||
@override
|
||||
String get txNoteHint => 'Например, продукты на неделю…';
|
||||
String get txNoteHint => 'Например, Пятерочка…';
|
||||
|
||||
@override
|
||||
String get txExtraInfoLabel => 'Доп. информация';
|
||||
|
||||
@override
|
||||
String get txExtraInfoHint => 'Например, номер чека, ссылка…';
|
||||
String get txExtraInfoHint => 'Например, продукты на неделю…';
|
||||
|
||||
@override
|
||||
String get txSaveButton => 'Добавить транзакцию';
|
||||
@@ -566,6 +566,110 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get parsingStatusParsedPartial => 'Частично';
|
||||
|
||||
@override
|
||||
String get parsingLogOnline => 'Онлайн';
|
||||
|
||||
@override
|
||||
String get parsingLogOffline => 'Офлайн';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterWaiting => 'Ждёт сети';
|
||||
|
||||
@override
|
||||
String get parsingDetailConfidence => 'Уверенность';
|
||||
|
||||
@override
|
||||
String get parsingDetailPipeline => 'Pipeline';
|
||||
|
||||
@override
|
||||
String get parsingDetailMessage => 'Сообщение';
|
||||
|
||||
@override
|
||||
String get parsingDetailDraft => 'Черновик';
|
||||
|
||||
@override
|
||||
String get parsingDetailRule => 'Предложение правила';
|
||||
|
||||
@override
|
||||
String get parsingDetailMeta => 'Мета';
|
||||
|
||||
@override
|
||||
String parsingDetailAttempt(int count, int max) {
|
||||
return 'Попытка $count/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get parsingDetailSource => 'Источник';
|
||||
|
||||
@override
|
||||
String get parsingDetailAmountScore => 'Сумма';
|
||||
|
||||
@override
|
||||
String get parsingDetailAccountScore => 'Счёт';
|
||||
|
||||
@override
|
||||
String get parsingDetailTypeScore => 'Тип';
|
||||
|
||||
@override
|
||||
String get parsingDetailMerchantScore => 'Мерчант';
|
||||
|
||||
@override
|
||||
String get parsingDetailCategoryScore => 'Категория';
|
||||
|
||||
@override
|
||||
String get parsingDetailTitle => 'Заголовок';
|
||||
|
||||
@override
|
||||
String get parsingDetailBody => 'Текст';
|
||||
|
||||
@override
|
||||
String get parsingDetailCard => 'Карта';
|
||||
|
||||
@override
|
||||
String get parsingDetailCurrency => 'Валюта';
|
||||
|
||||
@override
|
||||
String get parsingDetailKind => 'Вид';
|
||||
|
||||
@override
|
||||
String get parsingDetailDate => 'Дата';
|
||||
|
||||
@override
|
||||
String get parsingDetailMerchantRaw => 'Мерчант (сырой)';
|
||||
|
||||
@override
|
||||
String get parsingDetailMerchantCanonical => 'Мерчант (норм.)';
|
||||
|
||||
@override
|
||||
String get parsingDetailCounterparty => 'Контрагент';
|
||||
|
||||
@override
|
||||
String get parsingDetailCategorySuggestion => 'Подсказка категории';
|
||||
|
||||
@override
|
||||
String get parsingDetailAccountId => 'ID счёта';
|
||||
|
||||
@override
|
||||
String get parsingDetailCategoryId => 'ID категории';
|
||||
|
||||
@override
|
||||
String get parsingDetailTransferTo => 'Перевод на';
|
||||
|
||||
@override
|
||||
String get parsingDetailRuleCategory => 'Категория';
|
||||
|
||||
@override
|
||||
String get parsingDetailTransactionId => 'ID транзакции';
|
||||
|
||||
@override
|
||||
String get parsingDetailDedupHash => 'Dedup-хеш';
|
||||
|
||||
@override
|
||||
String get parsingDetailReceivedAt => 'Получено';
|
||||
|
||||
@override
|
||||
String get parsingDetailCreatedAt => 'Создано';
|
||||
|
||||
@override
|
||||
String get inboxTitle => 'Из уведомлений';
|
||||
|
||||
|
||||
+40
-3
@@ -98,10 +98,10 @@
|
||||
"txDateTimeLabel": "Дата и время",
|
||||
"txBalanceAfter": "Остаток после",
|
||||
"txTransferAfter": "После перевода",
|
||||
"txNoteLabel": "Заметка",
|
||||
"txNoteHint": "Например, продукты на неделю…",
|
||||
"txNoteLabel": "Мерчант",
|
||||
"txNoteHint": "Например, Пятерочка…",
|
||||
"txExtraInfoLabel": "Доп. информация",
|
||||
"txExtraInfoHint": "Например, номер чека, ссылка…",
|
||||
"txExtraInfoHint": "Например, продукты на неделю…",
|
||||
"txSaveButton": "Добавить транзакцию",
|
||||
"txSaveEditButton": "Сохранить",
|
||||
"txTransferButton": "Перевести {amount}",
|
||||
@@ -236,6 +236,43 @@
|
||||
"parsingStatusPendingAi": "Ждёт AI",
|
||||
"parsingStatusParsedPartial": "Частично",
|
||||
|
||||
"parsingLogOnline": "Онлайн",
|
||||
"parsingLogOffline": "Офлайн",
|
||||
"parsingLogFilterWaiting": "Ждёт сети",
|
||||
|
||||
"parsingDetailConfidence": "Уверенность",
|
||||
"parsingDetailPipeline": "Pipeline",
|
||||
"parsingDetailMessage": "Сообщение",
|
||||
"parsingDetailDraft": "Черновик",
|
||||
"parsingDetailRule": "Предложение правила",
|
||||
"parsingDetailMeta": "Мета",
|
||||
"parsingDetailAttempt": "Попытка {count}/{max}",
|
||||
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
|
||||
"parsingDetailSource": "Источник",
|
||||
"parsingDetailAmountScore": "Сумма",
|
||||
"parsingDetailAccountScore": "Счёт",
|
||||
"parsingDetailTypeScore": "Тип",
|
||||
"parsingDetailMerchantScore": "Мерчант",
|
||||
"parsingDetailCategoryScore": "Категория",
|
||||
"parsingDetailTitle": "Заголовок",
|
||||
"parsingDetailBody": "Текст",
|
||||
"parsingDetailCard": "Карта",
|
||||
"parsingDetailCurrency": "Валюта",
|
||||
"parsingDetailKind": "Вид",
|
||||
"parsingDetailDate": "Дата",
|
||||
"parsingDetailMerchantRaw": "Мерчант (сырой)",
|
||||
"parsingDetailMerchantCanonical": "Мерчант (норм.)",
|
||||
"parsingDetailCounterparty": "Контрагент",
|
||||
"parsingDetailCategorySuggestion": "Подсказка категории",
|
||||
"parsingDetailAccountId": "ID счёта",
|
||||
"parsingDetailCategoryId": "ID категории",
|
||||
"parsingDetailTransferTo": "Перевод на",
|
||||
"parsingDetailRuleCategory": "Категория",
|
||||
"parsingDetailTransactionId": "ID транзакции",
|
||||
"parsingDetailDedupHash": "Dedup-хеш",
|
||||
"parsingDetailReceivedAt": "Получено",
|
||||
"parsingDetailCreatedAt": "Создано",
|
||||
|
||||
"inboxTitle": "Из уведомлений",
|
||||
"inboxSubtitle": "Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически.",
|
||||
"inboxEmpty": "Inbox пуст. Новые уведомления появятся здесь.",
|
||||
|
||||
@@ -36,8 +36,8 @@ class AppPalette extends ThemeExtension<AppPalette> {
|
||||
ink2: Color(0xFF6B6B66),
|
||||
line: Color(0xFFD8D5CC),
|
||||
line2: Color(0xFFB8B5AC),
|
||||
accent: Color(0xFF4A8A82),
|
||||
accentSoft: Color(0xFFDDE9E6),
|
||||
accent: Color(0xFFA68A64),
|
||||
accentSoft: Color(0xFFEBE1CC),
|
||||
positive: Color(0xFF6F8C69),
|
||||
negative: Color(0xFFB3675A),
|
||||
);
|
||||
@@ -50,8 +50,8 @@ class AppPalette extends ThemeExtension<AppPalette> {
|
||||
ink2: Color(0xFF8D8A83),
|
||||
line: Color(0xFF2E2D2A),
|
||||
line2: Color(0xFF4A4845),
|
||||
accent: Color(0xFF76B3A9),
|
||||
accentSoft: Color(0xFF23332F),
|
||||
accent: Color(0xFFC7A878),
|
||||
accentSoft: Color(0xFF2F2A1E),
|
||||
positive: Color(0xFF92B58A),
|
||||
negative: Color(0xFFD18D7E),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../data/parser/ai_parser.dart';
|
||||
import '../data/parser/confidence_scorer.dart';
|
||||
import '../data/parser/decision_gate.dart';
|
||||
import '../data/parser/draft_codec.dart';
|
||||
import '../data/parser/regex_parser.dart';
|
||||
import '../data/parser/rule_lookup.dart';
|
||||
import '../data/parser/rule_suggester.dart';
|
||||
import '../domain/entities/parse_draft.dart';
|
||||
@@ -29,7 +28,11 @@ Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
|
||||
|
||||
/// ParsingWorker (§5): слушает `raw_messages.pending` и прогоняет pipeline
|
||||
/// regex → (AI fallback) → resolver → rule_lookup → suggester → scorer → gate.
|
||||
/// AI → resolver → rule_lookup → suggester → scorer → gate.
|
||||
///
|
||||
/// Извлечение делает только AI: встроенные regex-шаблоны убраны (не было
|
||||
/// видимого пользователю слоя). Без согласия/ключа/сети сообщение уходит
|
||||
/// в Inbox на ручной разбор.
|
||||
///
|
||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
|
||||
@@ -102,19 +105,13 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
final settings = await ref.read(parsingSettingsControllerProvider.future);
|
||||
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
||||
|
||||
// 1. Regex (этап 1).
|
||||
final parsed = const RegexParser().parse(msg);
|
||||
if (parsed != null) {
|
||||
await _runPipeline(userId, msg, parsed, settings);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Regex не справился → AI fallback (этап 2) или legacy-поведение.
|
||||
await _handleRegexMiss(userId, msg, settings);
|
||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||
await _extractViaAi(userId, msg, settings);
|
||||
}
|
||||
|
||||
/// Ветка «regex не распознал»: зовём AI (если разрешено), иначе Inbox/ignored.
|
||||
Future<void> _handleRegexMiss(
|
||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||
/// иначе Inbox на ручной разбор.
|
||||
Future<void> _extractViaAi(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParsingSettings settings,
|
||||
@@ -122,6 +119,13 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
final settingsCtrl = ref.read(parsingSettingsControllerProvider.notifier);
|
||||
|
||||
// Нет цифр → это не операция (спам/реклама) → ignored, AI не тратим.
|
||||
if (looksLikeNonTransaction(msg.body)) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// Есть цифры → кандидат в транзакцию.
|
||||
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
|
||||
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
|
||||
// состояние контроллера могло устареть при смене суток.
|
||||
@@ -130,12 +134,8 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
final aiParser = aiAllowed ? await ref.read(aiParserProvider.future) : null;
|
||||
|
||||
if (aiParser == null) {
|
||||
// Legacy (Phase 1): баланс/реклама → ignored, иначе → Inbox вручную.
|
||||
if (looksLikeNonTransaction(msg.body)) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
} else {
|
||||
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
|
||||
}
|
||||
// AI недоступен → ручной разбор в Inbox.
|
||||
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Общий хвост pipeline (§5, шаги 3–8) для regex- и AI-draft.
|
||||
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
||||
Future<void> _runPipeline(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../domain/entities/bank_template.dart';
|
||||
|
||||
/// Встроенный набор RU-шаблонов (regex first, §6).
|
||||
///
|
||||
/// Шаблоны универсальны (не привязаны к конкретным банкам) и покрывают
|
||||
/// распространённые форматы РФ: покупка/списание с карты, зачисление,
|
||||
/// СБП-перевод по телефону, возврат. Если ни один не совпал — этап 2 (AI,
|
||||
/// Phase 2); в Phase 1 это `parsed_partial` → Inbox.
|
||||
///
|
||||
/// Группы извлечения нумерованные (`$1`, `$2`, …) — соответствуют capture-
|
||||
/// группам в [BankTemplate.pattern]. Матч регистронезависимый.
|
||||
const List<BankTemplate> bankTemplatesCatalog = [
|
||||
// 1. Покупка: сумма → ₽ → *last4 → мерчант (Сбер-подобный формат).
|
||||
// «Покупка 1240р Карта *3456 PYATEROCHKA 14:05 Баланс: 5000р»
|
||||
BankTemplate(
|
||||
key: 'purchase_amount_card_merchant_ru',
|
||||
pattern:
|
||||
r'(?:Покупка|Оплата|Списание)\s+(\d[\d\s., ]*)\s*(?:₽|руб\.?|р\.?|RUB)[\s\S]*?\*(\d{4})\s+([^\d\n][^\n]*?)(?=\s+\d{1,2}[:.]\d{2}|\s+Баланс|\s+Доступно|[.;]|$)',
|
||||
extract: {'amount': r'$1', 'cardLast4': r'$2', 'merchant': r'$3'},
|
||||
type: TransactionType.expense,
|
||||
),
|
||||
|
||||
// 2. Покупка: *last4 → сумма ₽ → мерчант (Тинькофф-подобный формат).
|
||||
// «Покупка. Карта *3456. 1 240 ₽. Пятёрочка. Доступно 5 000 ₽»
|
||||
BankTemplate(
|
||||
key: 'purchase_card_amount_merchant_ru',
|
||||
pattern:
|
||||
r'(?:Покупка|Оплата|Списание)[\s\S]*?\*(\d{4})[\s\S]*?(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)\.?\s+([^\d\n][^\n.]*?)(?=[.]|\s+Доступно|\s+Баланс|$)',
|
||||
extract: {'cardLast4': r'$1', 'amount': r'$2', 'merchant': r'$3'},
|
||||
type: TransactionType.expense,
|
||||
),
|
||||
|
||||
// 3. Зачисление / пополнение / поступление.
|
||||
// «Зачисление 5000 ₽ Карта *3456 ...»
|
||||
BankTemplate(
|
||||
key: 'income_card_ru',
|
||||
pattern:
|
||||
r'(?:Зачисление|Пополнение|Поступление)\s+(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?\*(\d{4})',
|
||||
extract: {'amount': r'$1', 'cardLast4': r'$2'},
|
||||
type: TransactionType.income,
|
||||
),
|
||||
|
||||
// 4. СБП-перевод по телефону.
|
||||
// «Перевод 1000 ₽ на +79991234567 ...»
|
||||
BankTemplate(
|
||||
key: 'sbp_phone_ru',
|
||||
pattern:
|
||||
r'(?:Перевод|СБП|Отправлен[оы]?)[\s\S]*?(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?(\+7\d{10})',
|
||||
extract: {'amount': r'$1', 'phone': r'$2'},
|
||||
type: TransactionType.expense,
|
||||
),
|
||||
|
||||
// 5. Возврат покупки.
|
||||
// «Возврат 1240 ₽ Карта *3456 ...»
|
||||
BankTemplate(
|
||||
key: 'refund_card_ru',
|
||||
pattern:
|
||||
r'(?:Возврат|Refund)\s+(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?\*(\d{4})',
|
||||
extract: {'amount': r'$1', 'cardLast4': r'$2'},
|
||||
type: TransactionType.income,
|
||||
),
|
||||
];
|
||||
@@ -137,12 +137,8 @@ bool _amountAppearsInBody(int amountMinor, String body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Сообщение похоже на «не транзакцию» (баланс/реклама) — §8.6 последнее правило.
|
||||
bool looksLikeNonTransaction(String body) {
|
||||
final lower = body.toLowerCase();
|
||||
final hasBalanceWord = lower.contains('баланс') || lower.contains('доступно');
|
||||
final hasTxKeyword = RegExp(
|
||||
r'покупка|оплата|списание|зачисление|пополнение|поступление|перевод|возврат')
|
||||
.hasMatch(lower);
|
||||
return hasBalanceWord && !hasTxKeyword;
|
||||
}
|
||||
/// Сообщение похоже на «не транзакцию» (спам/реклама): в теле нет ни одной
|
||||
/// цифры. Сообщения с цифрами считаем кандидатами в транзакцию и отдаём в AI;
|
||||
/// окончательный вердикт «не транзакция» может вынести сама модель
|
||||
/// (AI вернёт type="ignored").
|
||||
bool looksLikeNonTransaction(String body) => !RegExp(r'\d').hasMatch(body);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../domain/entities/bank_template.dart';
|
||||
import '../../domain/entities/parse_draft.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/enums.dart';
|
||||
import '../bank_templates/bank_templates_catalog.dart';
|
||||
|
||||
/// Этап 1 pipeline: разбор тела уведомления встроенными regex-шаблонами.
|
||||
///
|
||||
/// Возвращает `null`, если ни один шаблон не совпал — тогда pipeline уходит
|
||||
/// на AI fallback (Phase 2) или в `parsed_partial`.
|
||||
class RegexParser {
|
||||
const RegexParser([this.templates = bankTemplatesCatalog]);
|
||||
|
||||
final List<BankTemplate> templates;
|
||||
|
||||
ParseDraft? parse(RawMessage msg) {
|
||||
for (final t in templates) {
|
||||
if (!t.enabled) continue;
|
||||
final re = RegExp(t.pattern, caseSensitive: false);
|
||||
final m = re.firstMatch(msg.body);
|
||||
if (m == null) continue;
|
||||
|
||||
final amountStr = _group(t.extract['amount'], m);
|
||||
final amount = amountStr == null ? null : parseAmountMinor(amountStr);
|
||||
if (amount == null || amount <= 0) continue;
|
||||
|
||||
return ParseDraft(
|
||||
rawMessageId: msg.id,
|
||||
type: t.type,
|
||||
amount: amount,
|
||||
currency: 'RUB',
|
||||
cardLast4: _group(t.extract['cardLast4'], m),
|
||||
merchantRaw: _group(t.extract['merchant'], m)?.trim(),
|
||||
counterpartyPhone: _group(t.extract['phone'], m),
|
||||
kind: _kindFor(t.type),
|
||||
source: ParseSource.regex,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Извлекает значение по back-reference вида `$1` из совпадения.
|
||||
String? _group(String? ref, RegExpMatch m) {
|
||||
if (ref == null) return null;
|
||||
final n = int.tryParse(ref.replaceAll(r'$', ''));
|
||||
if (n == null || n > m.groupCount) return null;
|
||||
final v = m.group(n);
|
||||
return (v == null || v.isEmpty) ? null : v;
|
||||
}
|
||||
|
||||
TxKind _kindFor(TransactionType type) => switch (type) {
|
||||
TransactionType.expense => TxKind.purchase,
|
||||
TransactionType.income => TxKind.transferIn,
|
||||
TransactionType.transfer => TxKind.transferOut,
|
||||
};
|
||||
}
|
||||
|
||||
/// Парсит денежную строку РФ-формата в минорные единицы (копейки).
|
||||
///
|
||||
/// Поддерживает пробелы/неразрывные пробелы как разделители тысяч и
|
||||
/// `,`/`.` как десятичный разделитель: `«1 240,50»` → 124050, `«1240»` → 124000.
|
||||
int? parseAmountMinor(String raw) {
|
||||
final s = raw.replaceAll(RegExp(r'\s'), '');
|
||||
if (s.isEmpty) return null;
|
||||
|
||||
final lastDot = s.lastIndexOf('.');
|
||||
final lastComma = s.lastIndexOf(',');
|
||||
final decPos = lastDot > lastComma ? lastDot : lastComma;
|
||||
|
||||
String intPart;
|
||||
String fracPart;
|
||||
if (decPos >= 0) {
|
||||
final after = s.substring(decPos + 1);
|
||||
// Десятичный разделитель — только если 1–2 цифры в хвосте.
|
||||
if (after.isNotEmpty && after.length <= 2 && _digitsOnly(after)) {
|
||||
intPart = s.substring(0, decPos);
|
||||
fracPart = after.padRight(2, '0');
|
||||
} else {
|
||||
intPart = s;
|
||||
fracPart = '00';
|
||||
}
|
||||
} else {
|
||||
intPart = s;
|
||||
fracPart = '00';
|
||||
}
|
||||
|
||||
intPart = intPart.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (intPart.isEmpty) return null;
|
||||
|
||||
final minor = int.tryParse(intPart + fracPart.substring(0, 2));
|
||||
return minor;
|
||||
}
|
||||
|
||||
bool _digitsOnly(String s) => RegExp(r'^\d+$').hasMatch(s);
|
||||
@@ -1,30 +0,0 @@
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
|
||||
/// Встроенный regex-шаблон для разбора текста уведомления.
|
||||
///
|
||||
/// Шаблоны хранятся в коде (bank_templates_catalog.dart), не в БД.
|
||||
/// Пользовательские шаблоны (Advanced settings) хранятся в отдельной таблице,
|
||||
/// но используют ту же структуру.
|
||||
///
|
||||
/// [extract] — именованные capture-группы: ключ = имя поля (amount, cardLast4),
|
||||
/// значение = back-reference вида r'\$1'.
|
||||
///
|
||||
/// Шаблоны RU-only в MVP; локализация шаблонов отложена.
|
||||
class BankTemplate {
|
||||
const BankTemplate({
|
||||
required this.key,
|
||||
required this.pattern,
|
||||
required this.extract,
|
||||
required this.type,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String pattern;
|
||||
final Map<String, String> extract;
|
||||
final TransactionType type;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
String toString() => 'BankTemplate($key)';
|
||||
}
|
||||
+365
-62
@@ -7,15 +7,21 @@ import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../../home/presentation/widgets/money_text.dart';
|
||||
import '../../../user/application/active_user_controller.dart';
|
||||
import '../../application/ai_providers.dart';
|
||||
import '../../application/inbox_controller.dart';
|
||||
import '../../data/parser/draft_codec.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/enums.dart';
|
||||
import '../widgets/confidence_badge.dart';
|
||||
|
||||
enum _LogFilter { all, inbox, applied, ignored, failed }
|
||||
/// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7).
|
||||
const _maxParseAttempts = 5;
|
||||
|
||||
enum _LogFilter { all, waiting, inbox, applied, ignored, failed }
|
||||
|
||||
/// Журнал парсинга: все `raw_messages` пользователя с их статусом — покрывает
|
||||
/// «архив raw_messages» и «показать игнорированные/авто-применённые» из Phase 1.
|
||||
/// Строка разворачивается по тапу и показывает полную отладочную информацию.
|
||||
class ParsingLogScreen extends ConsumerStatefulWidget {
|
||||
const ParsingLogScreen({super.key});
|
||||
|
||||
@@ -28,6 +34,10 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
||||
|
||||
bool _matches(RawMessage m) => switch (_filter) {
|
||||
_LogFilter.all => true,
|
||||
_LogFilter.waiting => m.status == RawMessageStatus.pendingAi ||
|
||||
m.status == RawMessageStatus.pending ||
|
||||
m.status == RawMessageStatus.parsing ||
|
||||
m.status == RawMessageStatus.parsed,
|
||||
_LogFilter.inbox => m.status == RawMessageStatus.inbox ||
|
||||
m.status == RawMessageStatus.parsedPartial,
|
||||
_LogFilter.applied => m.status == RawMessageStatus.applied,
|
||||
@@ -52,6 +62,7 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
||||
appBar: AppBar(
|
||||
backgroundColor: p.paper,
|
||||
title: Text(l10n.parsingLogTitle),
|
||||
actions: const [_OnlineIndicator(), SizedBox(width: 12)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -63,6 +74,7 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
||||
children: [
|
||||
for (final entry in <(_LogFilter, String)>[
|
||||
(_LogFilter.all, l10n.parsingLogFilterAll),
|
||||
(_LogFilter.waiting, l10n.parsingLogFilterWaiting),
|
||||
(_LogFilter.inbox, l10n.parsingLogFilterInbox),
|
||||
(_LogFilter.applied, l10n.parsingLogFilterApplied),
|
||||
(_LogFilter.ignored, l10n.parsingLogFilterIgnored),
|
||||
@@ -117,15 +129,52 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LogRow extends ConsumerWidget {
|
||||
const _LogRow({required this.message});
|
||||
|
||||
final RawMessage message;
|
||||
/// Живой индикатор сети в AppBar — читает [isOnlineProvider] (тот же поток,
|
||||
/// что использует воркер для ретрая `pending_ai`). Помогает понять, почему
|
||||
/// сообщение застряло в «Ждёт AI».
|
||||
class _OnlineIndicator extends ConsumerWidget {
|
||||
const _OnlineIndicator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final online = ref.watch(isOnlineProvider).value;
|
||||
final (IconData icon, Color color, String label) = switch (online) {
|
||||
true => (Icons.wifi, p.positive, l10n.parsingLogOnline),
|
||||
false => (Icons.wifi_off, p.ink2, l10n.parsingLogOffline),
|
||||
_ => (Icons.wifi, p.ink2, '—'),
|
||||
};
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogRow extends ConsumerStatefulWidget {
|
||||
const _LogRow({required this.message});
|
||||
|
||||
final RawMessage message;
|
||||
|
||||
@override
|
||||
ConsumerState<_LogRow> createState() => _LogRowState();
|
||||
}
|
||||
|
||||
class _LogRowState extends ConsumerState<_LogRow> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final message = widget.message;
|
||||
final bundle = decodeDraftBundle(message.draftJson);
|
||||
final draft = bundle?.draft;
|
||||
final merchant = draft?.merchantCanonical ?? draft?.merchantRaw;
|
||||
@@ -138,69 +187,86 @@ class _LogRow extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_StatusBadge(status: message.status),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
DateFormat('dd.MM HH:mm').format(message.receivedAt),
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message.packageName,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (draft != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
merchant ?? '—',
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600, color: p.ink),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Row(
|
||||
children: [
|
||||
_StatusBadge(status: message.status),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
DateFormat('dd.MM HH:mm').format(message.receivedAt),
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message.packageName,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
color: p.ink2,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (draft != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
merchant ?? '—',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
draft.type == TransactionType.expense
|
||||
? -draft.amount
|
||||
: draft.amount,
|
||||
color: draft.type == TransactionType.expense
|
||||
? p.negative
|
||||
: p.positive,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
withSign: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message.body,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||
maxLines: _expanded ? null : 2,
|
||||
overflow: _expanded ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
MoneyText(
|
||||
draft.type == TransactionType.expense
|
||||
? -draft.amount
|
||||
: draft.amount,
|
||||
color: draft.type == TransactionType.expense
|
||||
? p.negative
|
||||
: p.positive,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
withSign: true,
|
||||
),
|
||||
if (message.lastParseError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message.lastParseError!,
|
||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
||||
maxLines: _expanded ? null : 2,
|
||||
overflow: _expanded ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message.body,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (message.lastParseError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message.lastParseError!,
|
||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (_expanded) _DetailPanel(message: message, bundle: bundle),
|
||||
if (canRetry) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
@@ -226,6 +292,243 @@ class _LogRow extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Разворачиваемая панель с полной отладочной информацией по сообщению.
|
||||
/// Показывает только данные, уже сохранённые в БД (без новых запросов).
|
||||
class _DetailPanel extends StatelessWidget {
|
||||
const _DetailPanel({required this.message, required this.bundle});
|
||||
|
||||
final RawMessage message;
|
||||
final DraftBundle? bundle;
|
||||
|
||||
static final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final draft = bundle?.draft;
|
||||
final suggestion = bundle?.suggestion;
|
||||
|
||||
String? counterparty;
|
||||
if (draft != null) {
|
||||
final parts = [draft.counterpartyName, draft.counterpartyPhone]
|
||||
.where((e) => e != null && e.isNotEmpty)
|
||||
.join(' · ');
|
||||
counterparty = parts.isEmpty ? null : parts;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: p.paper2,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_section(context, l10n.parsingDetailConfidence, [
|
||||
_ConfidenceRow(
|
||||
label: l10n.parsingDetailAmountScore,
|
||||
score: message.confidenceAmount),
|
||||
_ConfidenceRow(
|
||||
label: l10n.parsingDetailAccountScore,
|
||||
score: message.confidenceAccount),
|
||||
_ConfidenceRow(
|
||||
label: l10n.parsingDetailTypeScore,
|
||||
score: message.confidenceType),
|
||||
_ConfidenceRow(
|
||||
label: l10n.parsingDetailMerchantScore,
|
||||
score: message.confidenceMerchant),
|
||||
_ConfidenceRow(
|
||||
label: l10n.parsingDetailCategoryScore,
|
||||
score: message.confidenceCategory),
|
||||
]),
|
||||
_section(context, l10n.parsingDetailPipeline, [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
l10n.parsingDetailAttempt(
|
||||
message.parseAttemptCount, _maxParseAttempts),
|
||||
style: TextStyle(fontSize: 12, color: p.ink, height: 1.3),
|
||||
),
|
||||
),
|
||||
if (draft != null)
|
||||
_kv(context, l10n.parsingDetailSource, draft.source.name),
|
||||
]),
|
||||
_section(context, l10n.parsingDetailMessage, [
|
||||
_kv(context, l10n.parsingDetailTitle, message.title),
|
||||
_kv(context, l10n.parsingDetailBody, message.body, selectable: true),
|
||||
]),
|
||||
if (draft != null)
|
||||
_section(context, l10n.parsingDetailDraft, [
|
||||
_kv(context, l10n.parsingDetailCurrency, draft.currency),
|
||||
_kv(context, l10n.parsingDetailCard, draft.cardLast4),
|
||||
_kv(context, l10n.parsingDetailKind, draft.kind?.name),
|
||||
_kv(
|
||||
context,
|
||||
l10n.parsingDetailDate,
|
||||
draft.dateTime == null
|
||||
? null
|
||||
: _dateFmt.format(draft.dateTime!)),
|
||||
_kv(context, l10n.parsingDetailMerchantRaw, draft.merchantRaw),
|
||||
_kv(context, l10n.parsingDetailMerchantCanonical,
|
||||
draft.merchantCanonical),
|
||||
_kv(context, l10n.parsingDetailCounterparty, counterparty),
|
||||
_kv(context, l10n.parsingDetailCategorySuggestion,
|
||||
draft.categorySuggestion),
|
||||
_kv(context, l10n.parsingDetailAccountId, draft.accountId,
|
||||
mono: true),
|
||||
_kv(context, l10n.parsingDetailCategoryId, draft.categoryId,
|
||||
mono: true),
|
||||
_kv(context, l10n.parsingDetailTransferTo,
|
||||
draft.transferToAccountId,
|
||||
mono: true),
|
||||
]),
|
||||
if (suggestion != null)
|
||||
_section(context, l10n.parsingDetailRule, [
|
||||
_kv(context, l10n.parsingDetailMerchantCanonical,
|
||||
suggestion.merchantCanonical),
|
||||
_kv(context, l10n.parsingDetailRuleCategory,
|
||||
suggestion.categoryName),
|
||||
]),
|
||||
_section(context, l10n.parsingDetailMeta, [
|
||||
_kv(context, l10n.parsingDetailTransactionId, message.transactionId,
|
||||
mono: true),
|
||||
_kv(context, l10n.parsingDetailDedupHash, message.dedupHash,
|
||||
mono: true),
|
||||
_kv(context, l10n.parsingDetailReceivedAt,
|
||||
_dateFmt.format(message.receivedAt)),
|
||||
_kv(context, l10n.parsingDetailCreatedAt,
|
||||
_dateFmt.format(message.createdAt)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Заголовок секции + её строки. Возвращает пустой блок, если ни одной
|
||||
/// заполненной строки нет (skip-null поведение `_kv`).
|
||||
Widget _section(BuildContext context, String title, List<Widget?> rows) {
|
||||
final visible = rows.whereType<Widget>().toList();
|
||||
if (visible.isEmpty) return const SizedBox.shrink();
|
||||
final p = context.palette;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: p.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
...visible,
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Строка «метка → значение». `null`/пустое значение → строка опускается.
|
||||
Widget? _kv(BuildContext context, String label, String? value,
|
||||
{bool mono = false, bool selectable = false}) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return _DetailRow(
|
||||
label: label, value: value, mono: mono, selectable: selectable);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.mono = false,
|
||||
this.selectable = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool mono;
|
||||
final bool selectable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final valueStyle = TextStyle(
|
||||
fontSize: 12,
|
||||
color: p.ink,
|
||||
height: 1.3,
|
||||
fontFamily: mono ? 'monospace' : null,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: selectable
|
||||
? SelectableText(value, style: valueStyle)
|
||||
: Text(value, style: valueStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfidenceRow extends StatelessWidget {
|
||||
const _ConfidenceRow({required this.label, required this.score});
|
||||
|
||||
final String label;
|
||||
final int? score;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final Color color;
|
||||
if (score == null) {
|
||||
color = p.ink2;
|
||||
} else if (score! >= 85) {
|
||||
color = p.positive;
|
||||
} else if (isWeakScore(score)) {
|
||||
color = p.negative;
|
||||
} else {
|
||||
color = p.accent;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(label,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
score?.toString() ?? '—',
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w700, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
@Tags(['integration'])
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.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/core/providers/database_provider.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/secure/ai_key_store.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
||||
|
||||
/// Интеграционные тесты AI-обработки уведомлений (§7), бьющие в РЕАЛЬНЫЙ
|
||||
/// OpenRouter API. Проверяют, что после получения сообщения оно
|
||||
/// автоматически подхватывается [ParsingWorker] и проходит pipeline
|
||||
/// AI → терминальный статус (inbox / ignored / failed).
|
||||
///
|
||||
/// Ключ НЕ хардкодится: берётся из --dart-define=OPENROUTER_API_KEY=...
|
||||
/// Без ключа сетевые тесты помечаются skip (плоский `flutter test` остаётся
|
||||
/// зелёным и офлайн). Тест с заведомо плохим ключом сети не требует и идёт
|
||||
/// всегда.
|
||||
///
|
||||
/// flutter test test/features/notification_parsing/integration/ai_processing_integration_test.dart \
|
||||
/// --tags integration -p vm \
|
||||
/// --dart-define=OPENROUTER_API_KEY=sk-or-...
|
||||
const _apiKey = String.fromEnvironment('OPENROUTER_API_KEY');
|
||||
const _model =
|
||||
String.fromEnvironment('OPENROUTER_TEST_MODEL', defaultValue: kDefaultAiModel);
|
||||
|
||||
final Object _skip =
|
||||
_apiKey.isEmpty ? 'set --dart-define=OPENROUTER_API_KEY to run' : false;
|
||||
|
||||
const _userId = 'user-1';
|
||||
const _accountId = 'acc-1';
|
||||
|
||||
/// Подменяет secure storage, отдавая ключ из dart-define. Так собирается
|
||||
/// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/OpenRouterClient —
|
||||
/// без обращения к платформенному FlutterSecureStorage.
|
||||
class _FakeKeyStore extends AiKeyStore {
|
||||
const _FakeKeyStore(this.key) : super(const FlutterSecureStorage());
|
||||
|
||||
final String key;
|
||||
|
||||
@override
|
||||
Future<String?> getApiKey() async => key;
|
||||
|
||||
@override
|
||||
Future<bool> hasKey() async => key.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Создаёт контейнер с реальной БД и AI-обвязкой на заданном ключе.
|
||||
ProviderContainer _container(AppDatabase db, String apiKey) {
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
appDatabaseProvider.overrideWithValue(db),
|
||||
aiKeyStoreProvider.overrideWithValue(_FakeKeyStore(apiKey)),
|
||||
// connectivity_plus не имеет платформенного биндинга под flutter test —
|
||||
// всегда «онлайн», иначе воркер уведёт сообщение в pending_ai.
|
||||
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _seed(AppDatabase db, {List<String> categories = const []}) async {
|
||||
await db.usersDao.insertUser(
|
||||
UsersTableCompanion.insert(id: _userId, name: 'Тест'),
|
||||
);
|
||||
await db.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(
|
||||
id: _accountId,
|
||||
userId: _userId,
|
||||
name: 'Основной',
|
||||
),
|
||||
);
|
||||
var i = 0;
|
||||
for (final name in categories) {
|
||||
await db.categoriesDao.insertCategory(
|
||||
CategoriesTableCompanion.insert(
|
||||
id: 'cat-${i++}',
|
||||
userId: _userId,
|
||||
name: name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Активирует воркер: помимо чтения самого провайдера держим прямого слушателя
|
||||
/// на его входном потоке [pendingMessagesProvider]. Без внешнего слушателя
|
||||
/// autoDispose-стрим не подписывается на Drift и `ref.listen` внутри воркера не
|
||||
/// получает событий (в приложении эту роль играет `HomeScreen`).
|
||||
void _activateWorker(ProviderContainer c) {
|
||||
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
|
||||
c.read(parsingWorkerProvider(_userId));
|
||||
}
|
||||
|
||||
/// Ждёт, пока сообщение [id] не выйдет из «промежуточных» статусов
|
||||
/// (pending / parsing / pending_ai) в терминальный.
|
||||
Future<RawMessage> _waitTerminal(
|
||||
RawMessagesRepository repo,
|
||||
String id, {
|
||||
Duration timeout = const Duration(seconds: 60),
|
||||
}) async {
|
||||
const transient = {
|
||||
RawMessageStatus.pending,
|
||||
RawMessageStatus.parsing,
|
||||
RawMessageStatus.pendingAi,
|
||||
};
|
||||
final completer = Completer<RawMessage>();
|
||||
late final StreamSubscription<List<RawMessage>> sub;
|
||||
sub = repo.watchAll(_userId).listen((list) {
|
||||
for (final m in list) {
|
||||
if (m.id == id && !transient.contains(m.status)) {
|
||||
if (!completer.isCompleted) completer.complete(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await completer.future.timeout(timeout);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const netTimeout = Timeout(Duration(seconds: 90));
|
||||
|
||||
// ── AI-обработка через реальный OpenRouter (требует ключ) ────────────────
|
||||
group('AI auto-processing (real OpenRouter)', () {
|
||||
late AppDatabase db;
|
||||
late ProviderContainer container;
|
||||
late RawMessagesRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
await _seed(db, categories: const ['Продукты', 'Кафе и рестораны']);
|
||||
container = _container(db, _apiKey);
|
||||
repo = container.read(rawMessagesRepositoryProvider);
|
||||
// Согласие на AI (по умолчанию false → AI не вызывается). Модель — из
|
||||
// dart-define/дефолта приложения.
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
await settings.setAiConsent(true);
|
||||
await settings.setAiModel(_model);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
container.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'английское уведомление о покупке → AI → Inbox с draft',
|
||||
() async {
|
||||
// Активируем воркер — он сам слушает pending и тянет pipeline.
|
||||
_activateWorker(container);
|
||||
|
||||
final inserted = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: 'com.example.bank',
|
||||
body: 'Payment of 1500 RUB at LENTA supermarket, card ending 7777',
|
||||
receivedAt: DateTime(2026, 5, 31, 12, 0),
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, inserted.id);
|
||||
|
||||
expect(msg.status, RawMessageStatus.inbox,
|
||||
reason: 'lastParseError=${msg.lastParseError}');
|
||||
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
expect(bundle, isNotNull, reason: 'draftJson must hold the AI draft');
|
||||
final draft = bundle!.draft;
|
||||
expect(draft.source, ParseSource.ai);
|
||||
expect(draft.type, TransactionType.expense);
|
||||
// Сумма в минорных единицах (~1500.00 ₽). Допускаем небольшой разброс.
|
||||
expect(draft.amount, greaterThanOrEqualTo(140000));
|
||||
expect(draft.amount, lessThanOrEqualTo(160000));
|
||||
expect(draft.merchantRaw, isNotNull);
|
||||
expect(draft.merchantRaw!.toUpperCase(), contains('LENTA'));
|
||||
|
||||
// Токены учтены в дневном счётчике.
|
||||
final settings =
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
expect(settings.tokensUsedToday, greaterThan(0));
|
||||
},
|
||||
timeout: netTimeout,
|
||||
skip: _skip,
|
||||
);
|
||||
|
||||
test(
|
||||
'не-транзакция (баланс/реклама) → AI помечает ignored',
|
||||
() async {
|
||||
_activateWorker(container);
|
||||
|
||||
final inserted = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: 'com.example.bank',
|
||||
body: 'Your card balance is 5000 RUB. Thank you for banking with us.',
|
||||
receivedAt: DateTime(2026, 5, 31, 12, 5),
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, inserted.id);
|
||||
expect(msg.status, RawMessageStatus.ignored,
|
||||
reason: 'lastParseError=${msg.lastParseError}');
|
||||
},
|
||||
timeout: netTimeout,
|
||||
skip: _skip,
|
||||
);
|
||||
|
||||
test(
|
||||
'покупка в продуктовом → AI предлагает категорию из списка пользователя',
|
||||
() async {
|
||||
_activateWorker(container);
|
||||
|
||||
final inserted = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: 'com.example.bank',
|
||||
body: 'Purchase 980 RUB at PYATEROCHKA grocery store, card 1234',
|
||||
receivedAt: DateTime(2026, 5, 31, 12, 10),
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, inserted.id);
|
||||
expect(msg.status, RawMessageStatus.inbox,
|
||||
reason: 'lastParseError=${msg.lastParseError}');
|
||||
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
expect(bundle, isNotNull);
|
||||
final draft = bundle!.draft;
|
||||
// Модель должна предложить категорию (имя — из списка пользователя).
|
||||
expect(draft.categorySuggestion, isNotNull);
|
||||
|
||||
// Если предложение правила подставило категорию — это одна из seed-категорий.
|
||||
final suggestion = bundle.suggestion;
|
||||
if (suggestion?.categoryName != null) {
|
||||
expect(
|
||||
const ['Продукты', 'Кафе и рестораны'],
|
||||
contains(suggestion!.categoryName),
|
||||
);
|
||||
expect(suggestion.categoryId, isNotNull);
|
||||
}
|
||||
},
|
||||
timeout: netTimeout,
|
||||
skip: _skip,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Деградация при неверном ключе (сеть не нужна, идёт всегда) ────────────
|
||||
group('AI auth failure handling', () {
|
||||
late AppDatabase db;
|
||||
late ProviderContainer container;
|
||||
late RawMessagesRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
await _seed(db);
|
||||
container = _container(db, 'sk-or-invalid-key-for-test');
|
||||
repo = container.read(rawMessagesRepositoryProvider);
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
await settings.setAiConsent(true);
|
||||
await settings.setAiModel(_model);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
container.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'неверный ключ (401/403) → сообщение failed, согласие на AI снимается',
|
||||
() async {
|
||||
_activateWorker(container);
|
||||
|
||||
final inserted = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: 'com.example.bank',
|
||||
body: 'Payment of 1500 RUB at LENTA supermarket, card ending 7777',
|
||||
receivedAt: DateTime(2026, 5, 31, 12, 0),
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, inserted.id);
|
||||
expect(msg.status, RawMessageStatus.failed);
|
||||
|
||||
final settings =
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
expect(settings.aiConsentGiven, isFalse,
|
||||
reason: 'auth failure must disable AI consent');
|
||||
},
|
||||
timeout: netTimeout,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -116,8 +116,15 @@ void main() {
|
||||
});
|
||||
|
||||
group('looksLikeNonTransaction', () {
|
||||
test('balance-only message → true', () {
|
||||
expect(looksLikeNonTransaction('Доступно по карте 5000 ₽'), isTrue);
|
||||
test('no digits (ad/spam) → true', () {
|
||||
expect(
|
||||
looksLikeNonTransaction('Скидки в нашем магазине только сегодня'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('balance with amount → false (есть цифры → кандидат, решит AI)', () {
|
||||
expect(looksLikeNonTransaction('Доступно по карте 5000 ₽'), isFalse);
|
||||
});
|
||||
|
||||
test('purchase message → false', () {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/regex_parser.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
|
||||
RawMessage _msg(String body, {String pkg = 'ru.sberbankmobile'}) {
|
||||
final now = DateTime(2026, 5, 29, 14, 5);
|
||||
return RawMessage(
|
||||
id: 'm1',
|
||||
userId: 'u1',
|
||||
packageName: pkg,
|
||||
body: body,
|
||||
receivedAt: now,
|
||||
dedupHash: 'h',
|
||||
status: RawMessageStatus.pending,
|
||||
createdAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const parser = RegexParser();
|
||||
|
||||
group('parseAmountMinor', () {
|
||||
test('plain integer → minor units', () {
|
||||
expect(parseAmountMinor('1240'), 124000);
|
||||
});
|
||||
|
||||
test('RU thousands separator + decimal comma', () {
|
||||
expect(parseAmountMinor('1 240,50'), 124050);
|
||||
});
|
||||
|
||||
test('dot decimal', () {
|
||||
expect(parseAmountMinor('99.90'), 9990);
|
||||
});
|
||||
|
||||
test('trailing dot in 3-digit group is NOT a decimal', () {
|
||||
// «1.240» — точка как разделитель тысяч (3 цифры после) → 124000.
|
||||
expect(parseAmountMinor('1.240'), 124000);
|
||||
});
|
||||
|
||||
test('empty / garbage → null', () {
|
||||
expect(parseAmountMinor(''), isNull);
|
||||
expect(parseAmountMinor(' '), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('RegexParser.parse', () {
|
||||
test('purchase amount→card→merchant (Сбер-подобный)', () {
|
||||
final d = parser.parse(
|
||||
_msg('Покупка 1240р Карта *3456 PYATEROCHKA 14:05 Баланс: 5000р'),
|
||||
);
|
||||
expect(d, isNotNull);
|
||||
expect(d!.type, TransactionType.expense);
|
||||
expect(d.amount, 124000);
|
||||
expect(d.cardLast4, '3456');
|
||||
expect(d.merchantRaw, 'PYATEROCHKA');
|
||||
expect(d.source, ParseSource.regex);
|
||||
expect(d.kind, TxKind.purchase);
|
||||
});
|
||||
|
||||
test('purchase card→amount→merchant (Тинькофф-подобный)', () {
|
||||
final d = parser.parse(
|
||||
_msg('Покупка. Карта *3456. 1 240 ₽. Пятёрочка. Доступно 5 000 ₽'),
|
||||
);
|
||||
expect(d, isNotNull);
|
||||
expect(d!.type, TransactionType.expense);
|
||||
expect(d.amount, 124000);
|
||||
expect(d.cardLast4, '3456');
|
||||
expect(d.merchantRaw, 'Пятёрочка');
|
||||
});
|
||||
|
||||
test('income (зачисление)', () {
|
||||
final d = parser.parse(_msg('Зачисление 5000 ₽ Карта *3456 Зарплата'));
|
||||
expect(d, isNotNull);
|
||||
expect(d!.type, TransactionType.income);
|
||||
expect(d.amount, 500000);
|
||||
expect(d.cardLast4, '3456');
|
||||
});
|
||||
|
||||
test('SBP transfer by phone', () {
|
||||
final d = parser.parse(_msg('Перевод 1000 ₽ на +79991234567 успешно'));
|
||||
expect(d, isNotNull);
|
||||
expect(d!.amount, 100000);
|
||||
expect(d.counterpartyPhone, '+79991234567');
|
||||
});
|
||||
|
||||
test('refund → income', () {
|
||||
final d = parser.parse(_msg('Возврат 1240 ₽ Карта *3456'));
|
||||
expect(d, isNotNull);
|
||||
expect(d!.type, TransactionType.income);
|
||||
expect(d.amount, 124000);
|
||||
});
|
||||
|
||||
test('non-matching body → null', () {
|
||||
expect(parser.parse(_msg('Ваш баланс по карте *3456 составляет 5000 ₽')),
|
||||
isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user