Stage 2 ai for sms

This commit is contained in:
2026-05-31 16:40:13 +03:00
parent a2d94a1451
commit 5dab1bd418
21 changed files with 712 additions and 46 deletions
+3 -1
View File
@@ -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)"
]
}
}
+19
View File
@@ -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",
+108
View File
@@ -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:
+55
View File
@@ -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';
+55
View File
@@ -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 => 'Добавить вручную';
+19
View File
@@ -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": "Счёт не определён",
+5
View File
@@ -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,
+1
View File
@@ -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',
@@ -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,
};
}
@@ -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
View File
@@ -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);