diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 79ef353..d026d92 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bd10b99..5323542 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 634f1c0..1df3d61 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6739897..92f8e65 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 126aa08..36c04f8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Добавить вручную'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c70927c..6c0e227 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Счёт не определён", diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index 7499ca2..2f7436b 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -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, diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart index 657c10e..c8d8fc1 100644 --- a/lib/src/app/router/app_routes.dart +++ b/lib/src/app/router/app_routes.dart @@ -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'; } diff --git a/lib/src/features/notification_parsing/application/inbox_controller.dart b/lib/src/features/notification_parsing/application/inbox_controller.dart index 91ce4c0..cd5e183 100644 --- a/lib/src/features/notification_parsing/application/inbox_controller.dart +++ b/lib/src/features/notification_parsing/application/inbox_controller.dart @@ -20,6 +20,11 @@ Stream> inboxMessages(Ref ref, String userId) => Stream inboxCount(Ref ref, String userId) => ref.watch(rawMessagesRepositoryProvider).watchInboxCount(userId); +/// Все сообщения пользователя (любой статус) — для «Журнала парсинга». +@riverpod +Stream> 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 retry(RawMessage message) async { + await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id); + } + /// «Игнорировать»: статус ignored, без правила. Future ignore(RawMessage message) async { await ref diff --git a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart index 9a30925..8faa93b 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart @@ -43,10 +43,19 @@ class RawMessagesDao extends DatabaseAccessor ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) .watch(); + /// Поток всех сообщений пользователя — для экрана «Журнал парсинга». + /// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху. + Stream> 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 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(userId)], readsFrom: {rawMessagesTable}, @@ -135,6 +144,18 @@ class RawMessagesDao extends DatabaseAccessor ), ); + /// Сброс сообщения в очередь на повторную обработку: статус → `pending`, + /// счётчик попыток → 0, прошлая ошибка очищается (чтобы AI-лимит `>= 5` + /// не блокировал повтор сразу). Воркер переобработает сообщение заново. + Future 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 incrementParseAttempts(String id) => customUpdate( 'UPDATE raw_messages SET parse_attempt_count = parse_attempt_count + 1 ' diff --git a/lib/src/features/notification_parsing/data/openrouter/ai_prompts.dart b/lib/src/features/notification_parsing/data/openrouter/ai_prompts.dart index 94324fc..450d41b 100644 --- a/lib/src/features/notification_parsing/data/openrouter/ai_prompts.dart +++ b/lib/src/features/notification_parsing/data/openrouter/ai_prompts.dart @@ -3,6 +3,7 @@ /// Вынесено в отдельный файл, чтобы текст промптов был в одном месте и легко /// правился без касания логики клиента/парсера. JSON-схема — рядом в /// [ai_schema.dart]; здесь только текстовые инструкции. +library; /// System-промпт. Требование структуры **дублируется текстом** (помимо /// `response_format: json_schema`), т.к. дешёвые модели OpenRouter часто diff --git a/lib/src/features/notification_parsing/data/openrouter/openrouter_client.dart b/lib/src/features/notification_parsing/data/openrouter/openrouter_client.dart index 02d814a..3cabfb2 100644 --- a/lib/src/features/notification_parsing/data/openrouter/openrouter_client.dart +++ b/lib/src/features/notification_parsing/data/openrouter/openrouter_client.dart @@ -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> 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 get _headers => { - 'Authorization': 'Bearer $_apiKey', + 'Authorization': 'Bearer $apiKey', 'Content-Type': 'application/json', // OpenRouter рекомендует указывать источник; необязательно. 'HTTP-Referer': 'https://newbudget.app', diff --git a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart index 3dcb247..080be5b 100644 --- a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart @@ -28,6 +28,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { .watchInbox(userId) .map((rows) => rows.map((r) => r.toDomain()).toList()); + @override + Stream> watchAll(String userId) => + _dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); + @override Stream watchInboxCount(String userId) => _dao.watchInboxCount(userId); @@ -109,4 +113,7 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { @override Future incrementParseAttempts(String id) => _dao.incrementParseAttempts(id); + + @override + Future resetForRetry(String id) => _dao.resetForRetry(id); } diff --git a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart index 802ae21..967d4f1 100644 --- a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart @@ -16,6 +16,9 @@ abstract interface class RawMessagesRepository { /// Сообщения, ожидающие подтверждения в Inbox. Stream> watchInbox(String userId); + /// Все сообщения пользователя (любой статус) — для «Журнала парсинга». + Stream> watchAll(String userId); + /// Реактивный счётчик Inbox — для бэджа на Home. Stream watchInboxCount(String userId); @@ -55,4 +58,7 @@ abstract interface class RawMessagesRepository { Future linkTransaction(String id, String transactionId); Future incrementParseAttempts(String id); + + /// Сброс сообщения на повторную обработку (status → pending, попытки → 0). + Future resetForRetry(String id); } diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart new file mode 100644 index 0000000..b296690 --- /dev/null +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart @@ -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 createState() => _ParsingLogScreenState(); +} + +class _ParsingLogScreenState extends ConsumerState { + _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, + }; +} diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart index 46be12e..d226e94 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart @@ -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), diff --git a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart index 9a623e2..7e11a05 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart @@ -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, diff --git a/test/features/transactions/application/transactions_controller_test.dart b/test/features/transactions/application/transactions_controller_test.dart index d65b90f..7907149 100644 --- a/test/features/transactions/application/transactions_controller_test.dart +++ b/test/features/transactions/application/transactions_controller_test.dart @@ -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 createCalls = []; final List 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, diff --git a/test/features/transactions/presentation/transaction_form_screen_test.dart b/test/features/transactions/presentation/transaction_form_screen_test.dart index 0cf183c..57e0e2e 100644 --- a/test/features/transactions/presentation/transaction_form_screen_test.dart +++ b/test/features/transactions/presentation/transaction_form_screen_test.dart @@ -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 calls = []; @override AsyncValue 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>.value(const []), ), + // Иначе тянет реальную БД через accountRepository → висящий drift-таймер. + defaultAccountProvider('test-uid').overrideWith( + (ref) => Stream.value(null), + ), categoriesByTypeStreamProvider('test-uid', CategoryType.expense).overrideWith( (ref) => Stream>.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 _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); diff --git a/test/features/user/users_controller_test.dart b/test/features/user/users_controller_test.dart index bc074e4..bb6edb8 100644 --- a/test/features/user/users_controller_test.dart +++ b/test/features/user/users_controller_test.dart @@ -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(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 91d1901..02cd099 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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);