Stage 2 ai for sms
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
"Bash(flutter analyze *)",
|
||||
"Bash(flutter gen-l10n *)",
|
||||
"Bash(dart run *)",
|
||||
"Bash(flutter test *)"
|
||||
"Bash(flutter test *)",
|
||||
"Bash(Select-String -Pattern \"Failing tests:|_test.dart:\" -Context 0,0)",
|
||||
"Bash(Select-Object -Last 40)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,23 @@
|
||||
"parsingDebugSectionTitle": "Debug",
|
||||
"parsingDebugInjectTile": "Inject a test notification",
|
||||
|
||||
"parsingLogTile": "Parsing log",
|
||||
"parsingLogTitle": "Parsing log",
|
||||
"parsingLogEmpty": "No messages yet. Incoming notifications will appear here.",
|
||||
"parsingLogError": "Failed to load the log.",
|
||||
"parsingLogFilterAll": "All",
|
||||
"parsingLogFilterInbox": "Inbox",
|
||||
"parsingLogFilterApplied": "Applied",
|
||||
"parsingLogFilterIgnored": "Ignored",
|
||||
"parsingLogFilterFailed": "Failed",
|
||||
"parsingStatusPending": "Pending",
|
||||
"parsingStatusInbox": "Inbox",
|
||||
"parsingStatusApplied": "Applied",
|
||||
"parsingStatusIgnored": "Ignored",
|
||||
"parsingStatusFailed": "Failed",
|
||||
"parsingStatusPendingAi": "Waiting for AI",
|
||||
"parsingStatusParsedPartial": "Partial",
|
||||
|
||||
"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.",
|
||||
@@ -231,6 +248,8 @@
|
||||
"inboxApplyAll": "Apply all",
|
||||
"inboxHideAll": "Hide all",
|
||||
"inboxUnrecognized": "Not recognized",
|
||||
"inboxParseErrorTitle": "Recognition error",
|
||||
"inboxRetry": "Try again",
|
||||
"inboxAddManually": "Add manually",
|
||||
"inboxNoCategory": "No category",
|
||||
"inboxAccountUnknown": "Account unknown",
|
||||
|
||||
@@ -974,6 +974,102 @@ abstract class AppLocalizations {
|
||||
/// **'Вставить тестовое уведомление'**
|
||||
String get parsingDebugInjectTile;
|
||||
|
||||
/// No description provided for @parsingLogTile.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Журнал парсинга'**
|
||||
String get parsingLogTile;
|
||||
|
||||
/// No description provided for @parsingLogTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Журнал парсинга'**
|
||||
String get parsingLogTitle;
|
||||
|
||||
/// No description provided for @parsingLogEmpty.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Сообщений пока нет. Входящие уведомления появятся здесь.'**
|
||||
String get parsingLogEmpty;
|
||||
|
||||
/// No description provided for @parsingLogError.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Не удалось загрузить журнал.'**
|
||||
String get parsingLogError;
|
||||
|
||||
/// No description provided for @parsingLogFilterAll.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Все'**
|
||||
String get parsingLogFilterAll;
|
||||
|
||||
/// No description provided for @parsingLogFilterInbox.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Inbox'**
|
||||
String get parsingLogFilterInbox;
|
||||
|
||||
/// No description provided for @parsingLogFilterApplied.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Применённые'**
|
||||
String get parsingLogFilterApplied;
|
||||
|
||||
/// No description provided for @parsingLogFilterIgnored.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Игнорированные'**
|
||||
String get parsingLogFilterIgnored;
|
||||
|
||||
/// No description provided for @parsingLogFilterFailed.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ошибки'**
|
||||
String get parsingLogFilterFailed;
|
||||
|
||||
/// No description provided for @parsingStatusPending.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'В очереди'**
|
||||
String get parsingStatusPending;
|
||||
|
||||
/// No description provided for @parsingStatusInbox.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Inbox'**
|
||||
String get parsingStatusInbox;
|
||||
|
||||
/// No description provided for @parsingStatusApplied.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Применено'**
|
||||
String get parsingStatusApplied;
|
||||
|
||||
/// No description provided for @parsingStatusIgnored.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Игнорировано'**
|
||||
String get parsingStatusIgnored;
|
||||
|
||||
/// No description provided for @parsingStatusFailed.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ошибка'**
|
||||
String get parsingStatusFailed;
|
||||
|
||||
/// No description provided for @parsingStatusPendingAi.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ждёт AI'**
|
||||
String get parsingStatusPendingAi;
|
||||
|
||||
/// No description provided for @parsingStatusParsedPartial.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Частично'**
|
||||
String get parsingStatusParsedPartial;
|
||||
|
||||
/// No description provided for @inboxTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -1034,6 +1130,18 @@ abstract class AppLocalizations {
|
||||
/// **'Не распознано'**
|
||||
String get inboxUnrecognized;
|
||||
|
||||
/// No description provided for @inboxParseErrorTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ошибка распознавания'**
|
||||
String get inboxParseErrorTitle;
|
||||
|
||||
/// No description provided for @inboxRetry.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Попробовать снова'**
|
||||
String get inboxRetry;
|
||||
|
||||
/// No description provided for @inboxAddManually.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
|
||||
@@ -505,6 +505,55 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get parsingDebugInjectTile => 'Inject a test notification';
|
||||
|
||||
@override
|
||||
String get parsingLogTile => 'Parsing log';
|
||||
|
||||
@override
|
||||
String get parsingLogTitle => 'Parsing log';
|
||||
|
||||
@override
|
||||
String get parsingLogEmpty =>
|
||||
'No messages yet. Incoming notifications will appear here.';
|
||||
|
||||
@override
|
||||
String get parsingLogError => 'Failed to load the log.';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterInbox => 'Inbox';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterApplied => 'Applied';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterIgnored => 'Ignored';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterFailed => 'Failed';
|
||||
|
||||
@override
|
||||
String get parsingStatusPending => 'Pending';
|
||||
|
||||
@override
|
||||
String get parsingStatusInbox => 'Inbox';
|
||||
|
||||
@override
|
||||
String get parsingStatusApplied => 'Applied';
|
||||
|
||||
@override
|
||||
String get parsingStatusIgnored => 'Ignored';
|
||||
|
||||
@override
|
||||
String get parsingStatusFailed => 'Failed';
|
||||
|
||||
@override
|
||||
String get parsingStatusPendingAi => 'Waiting for AI';
|
||||
|
||||
@override
|
||||
String get parsingStatusParsedPartial => 'Partial';
|
||||
|
||||
@override
|
||||
String get inboxTitle => 'From notifications';
|
||||
|
||||
@@ -541,6 +590,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get inboxUnrecognized => 'Not recognized';
|
||||
|
||||
@override
|
||||
String get inboxParseErrorTitle => 'Recognition error';
|
||||
|
||||
@override
|
||||
String get inboxRetry => 'Try again';
|
||||
|
||||
@override
|
||||
String get inboxAddManually => 'Add manually';
|
||||
|
||||
|
||||
@@ -517,6 +517,55 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get parsingDebugInjectTile => 'Вставить тестовое уведомление';
|
||||
|
||||
@override
|
||||
String get parsingLogTile => 'Журнал парсинга';
|
||||
|
||||
@override
|
||||
String get parsingLogTitle => 'Журнал парсинга';
|
||||
|
||||
@override
|
||||
String get parsingLogEmpty =>
|
||||
'Сообщений пока нет. Входящие уведомления появятся здесь.';
|
||||
|
||||
@override
|
||||
String get parsingLogError => 'Не удалось загрузить журнал.';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterAll => 'Все';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterInbox => 'Inbox';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterApplied => 'Применённые';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterIgnored => 'Игнорированные';
|
||||
|
||||
@override
|
||||
String get parsingLogFilterFailed => 'Ошибки';
|
||||
|
||||
@override
|
||||
String get parsingStatusPending => 'В очереди';
|
||||
|
||||
@override
|
||||
String get parsingStatusInbox => 'Inbox';
|
||||
|
||||
@override
|
||||
String get parsingStatusApplied => 'Применено';
|
||||
|
||||
@override
|
||||
String get parsingStatusIgnored => 'Игнорировано';
|
||||
|
||||
@override
|
||||
String get parsingStatusFailed => 'Ошибка';
|
||||
|
||||
@override
|
||||
String get parsingStatusPendingAi => 'Ждёт AI';
|
||||
|
||||
@override
|
||||
String get parsingStatusParsedPartial => 'Частично';
|
||||
|
||||
@override
|
||||
String get inboxTitle => 'Из уведомлений';
|
||||
|
||||
@@ -552,6 +601,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get inboxUnrecognized => 'Не распознано';
|
||||
|
||||
@override
|
||||
String get inboxParseErrorTitle => 'Ошибка распознавания';
|
||||
|
||||
@override
|
||||
String get inboxRetry => 'Попробовать снова';
|
||||
|
||||
@override
|
||||
String get inboxAddManually => 'Добавить вручную';
|
||||
|
||||
|
||||
@@ -219,6 +219,23 @@
|
||||
"parsingDebugSectionTitle": "Отладка",
|
||||
"parsingDebugInjectTile": "Вставить тестовое уведомление",
|
||||
|
||||
"parsingLogTile": "Журнал парсинга",
|
||||
"parsingLogTitle": "Журнал парсинга",
|
||||
"parsingLogEmpty": "Сообщений пока нет. Входящие уведомления появятся здесь.",
|
||||
"parsingLogError": "Не удалось загрузить журнал.",
|
||||
"parsingLogFilterAll": "Все",
|
||||
"parsingLogFilterInbox": "Inbox",
|
||||
"parsingLogFilterApplied": "Применённые",
|
||||
"parsingLogFilterIgnored": "Игнорированные",
|
||||
"parsingLogFilterFailed": "Ошибки",
|
||||
"parsingStatusPending": "В очереди",
|
||||
"parsingStatusInbox": "Inbox",
|
||||
"parsingStatusApplied": "Применено",
|
||||
"parsingStatusIgnored": "Игнорировано",
|
||||
"parsingStatusFailed": "Ошибка",
|
||||
"parsingStatusPendingAi": "Ждёт AI",
|
||||
"parsingStatusParsedPartial": "Частично",
|
||||
|
||||
"inboxTitle": "Из уведомлений",
|
||||
"inboxSubtitle": "Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически.",
|
||||
"inboxEmpty": "Inbox пуст. Новые уведомления появятся здесь.",
|
||||
@@ -231,6 +248,8 @@
|
||||
"inboxApplyAll": "Учесть все",
|
||||
"inboxHideAll": "Скрыть разово",
|
||||
"inboxUnrecognized": "Не распознано",
|
||||
"inboxParseErrorTitle": "Ошибка распознавания",
|
||||
"inboxRetry": "Попробовать снова",
|
||||
"inboxAddManually": "Добавить вручную",
|
||||
"inboxNoCategory": "Без категории",
|
||||
"inboxAccountUnknown": "Счёт не определён",
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../features/home/presentation/screens/home_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/inbox_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/parsing_log_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/rule_editor_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/rules_list_screen.dart';
|
||||
@@ -134,6 +135,10 @@ GoRouter appRouter(Ref ref) {
|
||||
path: AppRoutes.parsingDebugInject,
|
||||
builder: (context, state) => const DebugInjectScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.parsingLog,
|
||||
builder: (context, state) => const ParsingLogScreen(),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => AppScaffold(
|
||||
navigationShell: navigationShell,
|
||||
|
||||
@@ -29,4 +29,5 @@ class AppRoutes {
|
||||
static const parsingRuleEditPattern = '/settings/parsing/rules/:id';
|
||||
static String parsingRuleEdit(String id) => '/settings/parsing/rules/$id';
|
||||
static const parsingDebugInject = '/settings/parsing/debug';
|
||||
static const parsingLog = '/settings/parsing/log';
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ Stream<List<RawMessage>> inboxMessages(Ref ref, String userId) =>
|
||||
Stream<int> inboxCount(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchInboxCount(userId);
|
||||
|
||||
/// Все сообщения пользователя (любой статус) — для «Журнала парсинга».
|
||||
@riverpod
|
||||
Stream<List<RawMessage>> parsingLog(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchAll(userId);
|
||||
|
||||
/// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя.
|
||||
@Riverpod(keepAlive: true)
|
||||
class InboxController extends _$InboxController {
|
||||
@@ -118,6 +123,12 @@ class InboxController extends _$InboxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// «Попробовать снова»: сбросить сообщение в очередь на повторный разбор
|
||||
/// (status → pending, попытки → 0). Воркер переобработает его заново.
|
||||
Future<void> retry(RawMessage message) async {
|
||||
await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id);
|
||||
}
|
||||
|
||||
/// «Игнорировать»: статус ignored, без правила.
|
||||
Future<void> ignore(RawMessage message) async {
|
||||
await ref
|
||||
|
||||
@@ -43,10 +43,19 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||
.watch();
|
||||
|
||||
/// Поток всех сообщений пользователя — для экрана «Журнал парсинга».
|
||||
/// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху.
|
||||
Stream<List<RawMessagesTableData>> watchAll(String userId, {int limit = 200}) =>
|
||||
(select(rawMessagesTable)
|
||||
..where((t) => t.userId.equals(userId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||
..limit(limit))
|
||||
.watch();
|
||||
|
||||
/// Реактивный счётчик для бэджа на Home (inbox + parsed_partial + failed).
|
||||
Stream<int> watchInboxCount(String userId) {
|
||||
final query = customSelect(
|
||||
"SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? "
|
||||
'SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? '
|
||||
"AND status IN ('inbox', 'parsedPartial', 'failed')",
|
||||
variables: [Variable<String>(userId)],
|
||||
readsFrom: {rawMessagesTable},
|
||||
@@ -135,6 +144,18 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
),
|
||||
);
|
||||
|
||||
/// Сброс сообщения в очередь на повторную обработку: статус → `pending`,
|
||||
/// счётчик попыток → 0, прошлая ошибка очищается (чтобы AI-лимит `>= 5`
|
||||
/// не блокировал повтор сразу). Воркер переобработает сообщение заново.
|
||||
Future<void> resetForRetry(String id) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
const RawMessagesTableCompanion(
|
||||
status: Value(RawMessageStatus.pending),
|
||||
parseAttemptCount: Value(0),
|
||||
lastParseError: Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
/// Инкремент счётчика попыток парсинга.
|
||||
Future<void> incrementParseAttempts(String id) => customUpdate(
|
||||
'UPDATE raw_messages SET parse_attempt_count = parse_attempt_count + 1 '
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/// Вынесено в отдельный файл, чтобы текст промптов был в одном месте и легко
|
||||
/// правился без касания логики клиента/парсера. JSON-схема — рядом в
|
||||
/// [ai_schema.dart]; здесь только текстовые инструкции.
|
||||
library;
|
||||
|
||||
/// System-промпт. Требование структуры **дублируется текстом** (помимо
|
||||
/// `response_format: json_schema`), т.к. дешёвые модели OpenRouter часто
|
||||
|
||||
@@ -7,14 +7,13 @@ import 'package:http/http.dart' as http;
|
||||
/// HTTP-клиент OpenRouter (§7). `client` инжектируется для тестируемости.
|
||||
class OpenRouterClient {
|
||||
OpenRouterClient({
|
||||
required http.Client client,
|
||||
required String apiKey,
|
||||
required this.client,
|
||||
required this.apiKey,
|
||||
this.timeout = const Duration(seconds: 20),
|
||||
}) : _client = client,
|
||||
_apiKey = apiKey;
|
||||
});
|
||||
|
||||
final http.Client _client;
|
||||
final String _apiKey;
|
||||
final http.Client client;
|
||||
final String apiKey;
|
||||
final Duration timeout;
|
||||
|
||||
static const _base = 'https://openrouter.ai/api/v1';
|
||||
@@ -45,7 +44,7 @@ class OpenRouterClient {
|
||||
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _client
|
||||
res = await client
|
||||
.post(
|
||||
Uri.parse('$_base/chat/completions'),
|
||||
headers: _headers,
|
||||
@@ -75,7 +74,7 @@ class OpenRouterClient {
|
||||
/// При любой ошибке возвращает пустой список (UI откатывается на статику).
|
||||
Future<List<String>> listModels() async {
|
||||
try {
|
||||
final res = await _client
|
||||
final res = await client
|
||||
.get(Uri.parse('$_base/models'), headers: _headers)
|
||||
.timeout(timeout);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) return const [];
|
||||
@@ -93,7 +92,7 @@ class OpenRouterClient {
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': 'Bearer $_apiKey',
|
||||
'Authorization': 'Bearer $apiKey',
|
||||
'Content-Type': 'application/json',
|
||||
// OpenRouter рекомендует указывать источник; необязательно.
|
||||
'HTTP-Referer': 'https://newbudget.app',
|
||||
|
||||
+7
@@ -28,6 +28,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
.watchInbox(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<List<RawMessage>> watchAll(String userId) =>
|
||||
_dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId);
|
||||
|
||||
@@ -109,4 +113,7 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
@override
|
||||
Future<void> incrementParseAttempts(String id) =>
|
||||
_dao.incrementParseAttempts(id);
|
||||
|
||||
@override
|
||||
Future<void> resetForRetry(String id) => _dao.resetForRetry(id);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ abstract interface class RawMessagesRepository {
|
||||
/// Сообщения, ожидающие подтверждения в Inbox.
|
||||
Stream<List<RawMessage>> watchInbox(String userId);
|
||||
|
||||
/// Все сообщения пользователя (любой статус) — для «Журнала парсинга».
|
||||
Stream<List<RawMessage>> watchAll(String userId);
|
||||
|
||||
/// Реактивный счётчик Inbox — для бэджа на Home.
|
||||
Stream<int> watchInboxCount(String userId);
|
||||
|
||||
@@ -55,4 +58,7 @@ abstract interface class RawMessagesRepository {
|
||||
Future<void> linkTransaction(String id, String transactionId);
|
||||
|
||||
Future<void> incrementParseAttempts(String id);
|
||||
|
||||
/// Сброс сообщения на повторную обработку (status → pending, попытки → 0).
|
||||
Future<void> resetForRetry(String id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/l10n/l10n.dart';
|
||||
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/inbox_controller.dart';
|
||||
import '../../data/parser/draft_codec.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/enums.dart';
|
||||
|
||||
enum _LogFilter { all, inbox, applied, ignored, failed }
|
||||
|
||||
/// Журнал парсинга: все `raw_messages` пользователя с их статусом — покрывает
|
||||
/// «архив raw_messages» и «показать игнорированные/авто-применённые» из Phase 1.
|
||||
class ParsingLogScreen extends ConsumerStatefulWidget {
|
||||
const ParsingLogScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ParsingLogScreen> createState() => _ParsingLogScreenState();
|
||||
}
|
||||
|
||||
class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
||||
_LogFilter _filter = _LogFilter.all;
|
||||
|
||||
bool _matches(RawMessage m) => switch (_filter) {
|
||||
_LogFilter.all => true,
|
||||
_LogFilter.inbox => m.status == RawMessageStatus.inbox ||
|
||||
m.status == RawMessageStatus.parsedPartial,
|
||||
_LogFilter.applied => m.status == RawMessageStatus.applied,
|
||||
_LogFilter.ignored => m.status == RawMessageStatus.ignored,
|
||||
_LogFilter.failed => m.status == RawMessageStatus.failed,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
||||
|
||||
if (userId == null) {
|
||||
return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink());
|
||||
}
|
||||
|
||||
final all = ref.watch(parsingLogProvider(userId));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
appBar: AppBar(
|
||||
backgroundColor: p.paper,
|
||||
title: Text(l10n.parsingLogTitle),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
for (final entry in <(_LogFilter, String)>[
|
||||
(_LogFilter.all, l10n.parsingLogFilterAll),
|
||||
(_LogFilter.inbox, l10n.parsingLogFilterInbox),
|
||||
(_LogFilter.applied, l10n.parsingLogFilterApplied),
|
||||
(_LogFilter.ignored, l10n.parsingLogFilterIgnored),
|
||||
(_LogFilter.failed, l10n.parsingLogFilterFailed),
|
||||
])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(entry.$2),
|
||||
selected: _filter == entry.$1,
|
||||
selectedColor: p.accentSoft,
|
||||
onSelected: (_) => setState(() => _filter = entry.$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: all.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(l10n.parsingLogError,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: p.ink2)),
|
||||
),
|
||||
),
|
||||
data: (messages) {
|
||||
final rows = messages.where(_matches).toList();
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(l10n.parsingLogEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: p.ink2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, _) => Container(height: 1, color: p.line),
|
||||
itemBuilder: (context, i) => _LogRow(message: rows[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogRow extends ConsumerWidget {
|
||||
const _LogRow({required this.message});
|
||||
|
||||
final RawMessage message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final bundle = decodeDraftBundle(message.draftJson);
|
||||
final draft = bundle?.draft;
|
||||
final merchant = draft?.merchantCanonical ?? draft?.merchantRaw;
|
||||
|
||||
final canRetry = message.status == RawMessageStatus.failed ||
|
||||
message.status == RawMessageStatus.ignored;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
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(
|
||||
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: 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 (canRetry) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(inboxControllerProvider.notifier).retry(message),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: p.ink,
|
||||
side: BorderSide(color: p.line),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(l10n.inboxRetry,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
final RawMessageStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final color = switch (status) {
|
||||
RawMessageStatus.failed => p.negative,
|
||||
RawMessageStatus.applied => p.positive,
|
||||
RawMessageStatus.ignored => p.ink2,
|
||||
_ => p.accent,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_label(context.l10n, status),
|
||||
style: TextStyle(
|
||||
fontSize: 11, fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(AppLocalizations l10n, RawMessageStatus status) =>
|
||||
switch (status) {
|
||||
RawMessageStatus.pending ||
|
||||
RawMessageStatus.parsing ||
|
||||
RawMessageStatus.parsed =>
|
||||
l10n.parsingStatusPending,
|
||||
RawMessageStatus.parsedPartial => l10n.parsingStatusParsedPartial,
|
||||
RawMessageStatus.pendingAi => l10n.parsingStatusPendingAi,
|
||||
RawMessageStatus.inbox => l10n.parsingStatusInbox,
|
||||
RawMessageStatus.applied => l10n.parsingStatusApplied,
|
||||
RawMessageStatus.ignored => l10n.parsingStatusIgnored,
|
||||
RawMessageStatus.failed => l10n.parsingStatusFailed,
|
||||
};
|
||||
}
|
||||
+5
@@ -72,6 +72,11 @@ class ParsingSettingsScreen extends ConsumerWidget {
|
||||
title: l10n.parsingRulesTile,
|
||||
onTap: () => context.push(AppRoutes.parsingRules),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
title: l10n.parsingLogTile,
|
||||
onTap: () => context.push(AppRoutes.parsingLog),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../home/presentation/widgets/money_text.dart';
|
||||
import '../../application/inbox_controller.dart';
|
||||
import '../../data/parser/draft_codec.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/enums.dart';
|
||||
import '../screens/rule_editor_screen.dart';
|
||||
import 'confidence_badge.dart';
|
||||
|
||||
@@ -43,15 +44,17 @@ class InboxCard extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: p.line),
|
||||
),
|
||||
child: bundle == null
|
||||
? _UnrecognizedBody(message: message)
|
||||
: _RecognizedBody(
|
||||
message: message,
|
||||
userId: userId,
|
||||
defaultAccountId: defaultAccountId,
|
||||
categoryById: categoryById,
|
||||
bundle: bundle,
|
||||
),
|
||||
child: message.status == RawMessageStatus.failed
|
||||
? _FailedBody(message: message)
|
||||
: bundle == null
|
||||
? _UnrecognizedBody(message: message)
|
||||
: _RecognizedBody(
|
||||
message: message,
|
||||
userId: userId,
|
||||
defaultAccountId: defaultAccountId,
|
||||
categoryById: categoryById,
|
||||
bundle: bundle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -303,6 +306,70 @@ class _UnrecognizedBody extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Карточка сообщения, упавшего при парсинге (§7): заголовок ошибки, текст
|
||||
/// `lastParseError`, сырой `body` и две кнопки — «Попробовать снова» / «Игнорировать».
|
||||
class _FailedBody extends ConsumerWidget {
|
||||
const _FailedBody({required this.message});
|
||||
|
||||
final RawMessage message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 18, color: p.negative),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(l10n.inboxParseErrorTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600, color: p.ink)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.lastParseError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(message.lastParseError!,
|
||||
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(message.body,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
icon: Icons.refresh,
|
||||
label: l10n.inboxRetry,
|
||||
onTap: () =>
|
||||
ref.read(inboxControllerProvider.notifier).retry(message),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
icon: Icons.close,
|
||||
label: l10n.inboxIgnore,
|
||||
onTap: () =>
|
||||
ref.read(inboxControllerProvider.notifier).ignore(message),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateRuleButton extends StatelessWidget {
|
||||
const _CreateRuleButton({
|
||||
required this.label,
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:new_budget/src/features/transactions/domain/repositories/transac
|
||||
|
||||
// ─── Fake ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _CreateCall {
|
||||
_CreateCall({
|
||||
class CreateCall {
|
||||
CreateCall({
|
||||
required this.userId,
|
||||
required this.accountId,
|
||||
this.categoryId,
|
||||
@@ -30,7 +30,7 @@ class _CreateCall {
|
||||
}
|
||||
|
||||
class FakeTransactionRepository implements TransactionRepository {
|
||||
final List<_CreateCall> createCalls = [];
|
||||
final List<CreateCall> createCalls = [];
|
||||
final List<String> deleteCalls = [];
|
||||
Object? nextCreateError;
|
||||
Object? nextDeleteError;
|
||||
@@ -51,7 +51,7 @@ class FakeTransactionRepository implements TransactionRepository {
|
||||
bool autoApplied = false,
|
||||
String? appliedByRuleId,
|
||||
}) async {
|
||||
createCalls.add(_CreateCall(
|
||||
createCalls.add(CreateCall(
|
||||
userId: userId,
|
||||
accountId: accountId,
|
||||
categoryId: categoryId,
|
||||
|
||||
@@ -26,8 +26,8 @@ class FakeActiveUserController extends ActiveUserController {
|
||||
User(id: 'test-uid', name: 'Test', createdAt: DateTime(2024));
|
||||
}
|
||||
|
||||
class _TxCall {
|
||||
_TxCall({
|
||||
class TxCall {
|
||||
TxCall({
|
||||
required this.userId,
|
||||
required this.accountId,
|
||||
this.categoryId,
|
||||
@@ -48,7 +48,7 @@ class _TxCall {
|
||||
}
|
||||
|
||||
class FakeTransactionsController extends TransactionsController {
|
||||
final List<_TxCall> calls = [];
|
||||
final List<TxCall> calls = [];
|
||||
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
@@ -68,7 +68,7 @@ class FakeTransactionsController extends TransactionsController {
|
||||
bool autoApplied = false,
|
||||
String? appliedByRuleId,
|
||||
}) async {
|
||||
calls.add(_TxCall(
|
||||
calls.add(TxCall(
|
||||
userId: userId,
|
||||
accountId: accountId,
|
||||
categoryId: categoryId,
|
||||
@@ -111,6 +111,10 @@ Widget _buildForm(FakeTransactionsController fakeCtrl) {
|
||||
accountsStreamProvider('test-uid').overrideWith(
|
||||
(ref) => Stream<List<Account>>.value(const []),
|
||||
),
|
||||
// Иначе тянет реальную БД через accountRepository → висящий drift-таймер.
|
||||
defaultAccountProvider('test-uid').overrideWith(
|
||||
(ref) => Stream<Account?>.value(null),
|
||||
),
|
||||
categoriesByTypeStreamProvider('test-uid', CategoryType.expense).overrideWith(
|
||||
(ref) => Stream<List<Category>>.value(const []),
|
||||
),
|
||||
@@ -135,6 +139,14 @@ ProviderContainer _container(WidgetTester tester) =>
|
||||
Finder _snackText(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
|
||||
|
||||
/// Кнопка Save лежит в самом низу прокручиваемой формы и в тестовом вьюпорте
|
||||
/// 800×600 оказывается за пределами экрана. Доскролливаем её перед тапом.
|
||||
Future<void> _tapSave(WidgetTester tester) async {
|
||||
await tester.ensureVisible(find.byType(FilledButton));
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
@@ -154,8 +166,7 @@ void main() {
|
||||
await tester.pump(); // activeUserControllerProvider разрешается
|
||||
|
||||
// Нажимаем Save не вводя сумму (draft.amountMinor == 0).
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(find.text('Enter an amount'), findsOneWidget);
|
||||
expect(fakeCtrl.calls, isEmpty);
|
||||
@@ -173,8 +184,7 @@ void main() {
|
||||
.setAmount(500);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(_snackText('Pick an account'), findsOneWidget);
|
||||
expect(fakeCtrl.calls, isEmpty);
|
||||
@@ -194,8 +204,7 @@ void main() {
|
||||
// categoryId остаётся null
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(_snackText('Pick a category'), findsOneWidget);
|
||||
expect(fakeCtrl.calls, isEmpty);
|
||||
@@ -217,8 +226,7 @@ void main() {
|
||||
// transferToAccountId остаётся null
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(_snackText('Pick a destination account'), findsOneWidget);
|
||||
expect(fakeCtrl.calls, isEmpty);
|
||||
@@ -240,8 +248,7 @@ void main() {
|
||||
notifier.setTransferToAccount('a-1');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(_snackText('Source and destination must differ'), findsOneWidget);
|
||||
expect(fakeCtrl.calls, isEmpty);
|
||||
@@ -264,8 +271,7 @@ void main() {
|
||||
notifier.setDate(testDate);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(fakeCtrl.calls, hasLength(1));
|
||||
final call = fakeCtrl.calls.first;
|
||||
@@ -293,8 +299,7 @@ void main() {
|
||||
notifier.setTransferToAccount('a-2');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(fakeCtrl.calls, hasLength(1));
|
||||
final call = fakeCtrl.calls.first;
|
||||
@@ -318,8 +323,7 @@ void main() {
|
||||
notifier.setMerchant(' '); // пробелы → null после trim
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(fakeCtrl.calls, hasLength(1));
|
||||
expect(fakeCtrl.calls.first.merchant, isNull);
|
||||
|
||||
@@ -344,7 +344,7 @@ void main() {
|
||||
|
||||
// listen, а не read(.future): иначе auto-dispose снесёт провайдер
|
||||
// до того, как future завершится.
|
||||
final sub = container.listen(usersStreamProvider.future, (_, __) {});
|
||||
final sub = container.listen(usersStreamProvider.future, (_, _) {});
|
||||
addTearDown(sub.close);
|
||||
|
||||
final users = await sub.read();
|
||||
|
||||
+12
-1
@@ -1,14 +1,25 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:new_budget/src/app/app.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
import 'package:new_budget/src/core/providers/database_provider.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App boots without crashes', (tester) async {
|
||||
await initializeDateFormatting('ru');
|
||||
// Реальная БД через drift_flutter оставляет висящий таймер после тиардауна.
|
||||
// Подменяем на in-memory, чтобы тест-вьюпорт не падал на pending timer.
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(child: NewBudgetApp()),
|
||||
ProviderScope(
|
||||
overrides: [appDatabaseProvider.overrideWithValue(db)],
|
||||
child: const NewBudgetApp(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
|
||||
Reference in New Issue
Block a user