Files
SandersandClaude Fable 5 f2ed501ed4 Unify parse rules: one rule = condition + actions, autoApply toggle, v3 migration
- Drop ParseRuleKind from parse_rules (enum survives only in rule_candidates):
  a rule is now a condition (pattern + matchMode + optional txTypeGuard) plus
  any set of actions (merchantCanonical/categoryId/accountId) or isIgnore
- Field-wise resolution: findClassificationRule fills merchant/category,
  findAccountRule feeds the account resolver - one message may use both
- Per-rule autoApply toggle + ruleAutoApplyEnabled gate check; off -> Inbox
  with full prefill
- Dedup guard in ParseRulesRepositoryImpl.create: same condition updates or
  reactivates the existing row instead of inserting a duplicate
- ParsingPipeline.reapplyRulesToInbox: sweep app inbox cards on cached AI
  drafts after createRule/ignoreWithRule (zero tokens)
- Schema v2 -> v3 migration (drop kind, add auto_apply) + migration_v3_test
- Rework rule_editor_screen into a single unified form; update rules list,
  rule cards, source app detail, l10n strings, and tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:24:06 +03:00

1169 lines
39 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../home/presentation/widgets/money_text.dart';
import '../../../transactions/presentation/screens/transaction_form_screen.dart';
import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
import '../../application/ai_providers.dart';
import '../../application/inbox_controller.dart';
import '../../data/parser/draft_codec.dart';
import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart';
import '../screens/rule_editor_screen.dart';
import 'confidence_badge.dart';
import 'gate_check_labels.dart';
import 'parse_error_labels.dart';
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
/// действия — «Создать правило», «Подтвердить», «Игнорировать».
class InboxCard extends ConsumerWidget {
const InboxCard({
super.key,
required this.message,
required this.userId,
required this.defaultAccountId,
required this.categoryById,
});
final RawMessage message;
final String userId;
final String? defaultAccountId;
final Map<String, Category> categoryById;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final bundle = decodeDraftBundle(message.draftJson);
return Container(
margin: const EdgeInsets.fromLTRB(12, 6, 12, 6),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: p.line),
),
child: message.status == RawMessageStatus.failed
? _FailedBody(message: message)
// pendingAi — ждёт сети/AI: причина + ручной ретрай (draftJson ещё нет).
: message.status == RawMessageStatus.pendingAi
? _PendingAiBody(message: message)
: bundle == null
? _UnrecognizedBody(message: message)
// pairedRawMessageId → склеенная пара «Перевод между счетами».
: bundle.pairedRawMessageId != null
? _TransferPairBody(
message: message,
userId: userId,
bundle: bundle,
)
// suggestion != null → знакомый мерчант: предлагаем правило.
// suggestion == null → нет мерчанта (только сумма) ИЛИ источник
// с selfMerchant → «Подтвердить» без создания правила.
: bundle.suggestion != null
? _RecognizedBody(
message: message,
userId: userId,
defaultAccountId: defaultAccountId,
categoryById: categoryById,
bundle: bundle,
)
: _ConfirmOnceBody(
message: message,
userId: userId,
defaultAccountId: defaultAccountId,
categoryById: categoryById,
bundle: bundle,
),
);
}
}
class _RecognizedBody extends ConsumerWidget {
const _RecognizedBody({
required this.message,
required this.userId,
required this.defaultAccountId,
required this.categoryById,
required this.bundle,
});
final RawMessage message;
final String userId;
final String? defaultAccountId;
final Map<String, Category> categoryById;
final DraftBundle bundle;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final draft = bundle.draft;
final sugg = bundle.suggestion;
final merchant = sugg?.merchantCanonical ??
draft.merchantCanonical ??
draft.merchantRaw ??
'—';
final categoryId = draft.categoryId ?? sugg?.categoryId;
final categoryName =
categoryId != null ? categoryById[categoryId]?.name : null;
final accountId = draft.accountId ?? defaultAccountId;
final signed =
draft.type == TransactionType.expense ? -draft.amount : draft.amount;
final amountColor =
draft.type == TransactionType.expense ? p.negative : p.positive;
final weakOther = isWeakScore(message.confidenceAmount) ||
isWeakScore(message.confidenceAccount) ||
isWeakScore(message.confidenceType);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
merchant,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: p.ink,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
MoneyText(signed,
color: amountColor, fontSize: 15, fontWeight: FontWeight.w700,
withSign: true),
],
),
const SizedBox(height: 4),
Row(
children: [
if (weakOther) ...[
const ConfidenceBadge(),
const SizedBox(width: 6),
],
Flexible(
child: Text(
[
categoryName ?? l10n.inboxNoCategory,
if (draft.cardLast4 != null) '· *${draft.cardLast4}',
].join(' '),
style: TextStyle(fontSize: 13, color: p.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
message.body,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (whyNotAutoText(context, bundle.failedChecks) != null) ...[
const SizedBox(height: 6),
Text(
whyNotAutoText(context, bundle.failedChecks)!,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
),
],
const SizedBox(height: 12),
_SplitActionButton(
label: categoryName != null
? l10n.inboxCreateRule(merchant, categoryName)
: l10n.inboxCreateRuleNoCategory(merchant),
editTooltip: l10n.inboxEditRuleTooltip,
onTap: () => _createRule(
context,
ref,
accountId: accountId,
categoryId: categoryId,
merchant: merchant,
),
onEdit: () => _openEditor(
context,
ref,
accountId: accountId,
categoryId: categoryId,
merchant: merchant,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _SecondaryButton(
icon: Icons.check,
label: l10n.inboxConfirm,
onTap: () async {
final acc = await _resolveAccount(context,
userId: userId, accountId: accountId);
if (acc == null || !context.mounted) return;
await _runLearning(
context,
() => ref
.read(inboxControllerProvider.notifier)
.confirmOnce(
userId: userId,
message: message,
draft: draft,
accountId: acc,
categoryId: categoryId,
),
);
},
),
),
const SizedBox(width: 8),
Expanded(
child: _SecondaryButton(
icon: Icons.close,
label: l10n.inboxIgnore,
onTap: () =>
ref.read(inboxControllerProvider.notifier).ignore(message),
),
),
],
),
],
);
}
Future<void> _createRule(
BuildContext context,
WidgetRef ref, {
required String? accountId,
required String? categoryId,
required String merchant,
}) async {
final draft = bundle.draft;
if (categoryId == null) {
await _openEditor(context, ref,
accountId: accountId, categoryId: categoryId, merchant: merchant);
return;
}
final acc =
await _resolveAccount(context, userId: userId, accountId: accountId);
if (acc == null || !context.mounted) return;
await _runLearning(
context,
() => ref.read(inboxControllerProvider.notifier).createRule(
userId: userId,
message: message,
draft: draft,
accountId: acc,
categoryId: categoryId,
merchantCanonical: merchant,
pattern: draft.merchantRaw ?? merchant,
),
);
}
Future<void> _openEditor(
BuildContext context,
WidgetRef ref, {
required String? accountId,
required String? categoryId,
required String merchant,
}) async {
final draft = bundle.draft;
final result = await context.push<RuleEditorResult?>(
AppRoutes.parsingRuleNew,
extra: RuleEditorPrefill(
packageName: message.packageName,
pattern: draft.merchantRaw ?? merchant,
merchantCanonical: merchant,
categoryId: categoryId,
accountId: accountId,
),
);
if (result == null || !context.mounted) return;
final acc = await _resolveAccount(context,
userId: userId, accountId: result.accountId ?? accountId);
if (acc == null || !context.mounted) return;
await _runLearning(
context,
() => ref.read(inboxControllerProvider.notifier).createRule(
userId: userId,
message: message,
draft: draft,
accountId: acc,
categoryId: result.categoryId,
merchantCanonical: result.merchantCanonical.isEmpty
? merchant
: result.merchantCanonical,
pattern: result.pattern,
matchMode: result.matchMode,
autoApply: result.autoApply,
),
);
}
}
/// Выполняет действие контроллера и показывает SnackBar при ошибке —
/// InboxController делает rethrow, и без обработки тап выглядит как no-op.
Future<void> _runReporting(
BuildContext context,
Future<void> Function() action,
) async {
try {
await action();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
/// То же для confirmOnce/createRule: контроллер возвращает true, когда счёт
/// только что записан дефолтом приложения (авто-обучение) — сообщаем об этом
/// SnackBar'ом, дальше похожие сообщения смогут применяться автоматически.
Future<void> _runLearning(
BuildContext context,
Future<bool> Function() action,
) async {
try {
final learned = await action();
if (learned && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.inboxAppDefaultSet)),
);
}
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
/// Счёт для подтверждения: переданный [accountId] (draft/дефолт), иначе —
/// пикер счёта. `null` из пикера = пользователь отменил.
Future<String?> _resolveAccount(
BuildContext context, {
required String userId,
String? accountId,
}) async {
if (accountId != null) return accountId;
return showAccountPicker(context, userId: userId);
}
/// Карточка без предложения правила (§9.1): уведомление без мерчанта (только
/// сумма) или источник с selfMerchant (Ozon). Создавать merchant→category
/// правило тут нельзя/бессмысленно: пользователь выбирает категорию
/// (обязательно) и подтверждает — либо уходит в полную форму карандашом.
class _ConfirmOnceBody extends ConsumerStatefulWidget {
const _ConfirmOnceBody({
required this.message,
required this.userId,
required this.defaultAccountId,
required this.categoryById,
required this.bundle,
});
final RawMessage message;
final String userId;
final String? defaultAccountId;
final Map<String, Category> categoryById;
final DraftBundle bundle;
@override
ConsumerState<_ConfirmOnceBody> createState() => _ConfirmOnceBodyState();
}
class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
String? _categoryId;
@override
void initState() {
super.initState();
final draft = widget.bundle.draft;
// Предзаполняем категорию: из draft (если правило её проставило) или из
// AI-подсказки categorySuggestion, сматченной по имени на справочник.
_categoryId = draft.categoryId ?? _matchSuggestion(draft.categorySuggestion);
}
String? _matchSuggestion(String? name) {
if (name == null) return null;
final target = name.trim().toLowerCase();
for (final c in widget.categoryById.values) {
if (c.name.trim().toLowerCase() == target) return c.id;
}
return null;
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final message = widget.message;
final draft = widget.bundle.draft;
final merchant = draft.merchantCanonical ??
draft.merchantRaw ??
message.title ??
'—';
final categoryName =
_categoryId != null ? widget.categoryById[_categoryId]?.name : null;
// Чипы частых категорий этого приложения — быстрый выбор без пикера.
final topCategoryIds = ref
.watch(topConfirmCategoriesProvider(
widget.userId, message.packageName, draft.type))
.value ??
const <String>[];
final chipCategories = topCategoryIds
.map((id) => widget.categoryById[id])
.whereType<Category>()
.where((c) => !c.archived)
.take(4)
.toList();
final signed =
draft.type == TransactionType.expense ? -draft.amount : draft.amount;
final amountColor =
draft.type == TransactionType.expense ? p.negative : p.positive;
final whyNot = whyNotAutoText(context, widget.bundle.failedChecks);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
merchant,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: p.ink,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
MoneyText(signed,
color: amountColor,
fontSize: 15,
fontWeight: FontWeight.w700,
withSign: true),
],
),
if (draft.cardLast4 != null) ...[
const SizedBox(height: 4),
Text('*${draft.cardLast4}',
style: TextStyle(fontSize: 13, color: p.ink2)),
],
const SizedBox(height: 8),
Text(
message.body,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (whyNot != null) ...[
const SizedBox(height: 6),
Text(whyNot,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
],
const SizedBox(height: 12),
if (chipCategories.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final c in chipCategories)
_CategoryChip(
label: c.name,
selected: _categoryId == c.id,
onTap: () => setState(() => _categoryId = c.id),
),
],
),
const SizedBox(height: 8),
],
// Строка выбора категории — категория обязательна для подтверждения.
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () async {
final id = await showCategoryPicker(
context,
userId: widget.userId,
type: draft.type == TransactionType.income
? CategoryType.income
: CategoryType.expense,
currentCategoryId: _categoryId,
);
if (id != null) setState(() => _categoryId = id);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: p.paper,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: p.line),
),
child: Row(
children: [
Icon(Icons.category_outlined, size: 18, color: p.ink2),
const SizedBox(width: 10),
Expanded(
child: Text(
categoryName ?? l10n.inboxNoCategory,
style: TextStyle(fontSize: 14, color: p.ink),
),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
),
),
if (_categoryId == null) ...[
const SizedBox(height: 6),
Text(l10n.inboxCategoryRequiredHint,
style: TextStyle(fontSize: 12, color: p.ink2)),
],
const SizedBox(height: 10),
Row(
children: [
Expanded(
flex: 2,
child: _SplitActionButton(
label: l10n.inboxConfirm,
editTooltip: l10n.inboxEditRuleTooltip,
onTap: _categoryId == null ? null : _confirm,
onEdit: _openFullForm,
),
),
const SizedBox(width: 8),
Expanded(
child: _SecondaryButton(
icon: Icons.close,
label: l10n.inboxIgnore,
onTap: () =>
ref.read(inboxControllerProvider.notifier).ignore(message),
),
),
],
),
],
);
}
/// Галочка: мгновенная транзакция с выбранной категорией. Без счёта —
/// открывает пикер счёта (отмена пикера = ничего не делаем).
Future<void> _confirm() async {
final message = widget.message;
final draft = widget.bundle.draft;
final acc = await _resolveAccount(
context,
userId: widget.userId,
accountId: draft.accountId ?? widget.defaultAccountId,
);
if (acc == null || !mounted) return;
await _runLearning(
context,
() => ref.read(inboxControllerProvider.notifier).confirmOnce(
userId: widget.userId,
message: message,
draft: draft,
accountId: acc,
categoryId: _categoryId,
),
);
}
/// Карандаш: полная форма транзакции с префиллом — там можно вписать
/// реального мерчанта. После сохранения сообщение линкуется (→ applied).
Future<void> _openFullForm() async {
final message = widget.message;
final draft = widget.bundle.draft;
final txId = await context.push<String?>(
AppRoutes.transactionNew,
extra: TransactionFormPrefill(
type: draft.type,
amountMinor: draft.amount,
date: draft.dateTime ?? message.receivedAt,
accountId: draft.accountId ?? widget.defaultAccountId,
categoryId: _categoryId,
merchant: draft.merchantCanonical ?? draft.merchantRaw ?? message.title,
rawMessageId: message.id,
),
);
if (txId == null || txId.isEmpty) return;
await ref
.read(inboxControllerProvider.notifier)
.markApplied(message, txId);
}
}
/// Склеенная пара «Перевод между счетами»: сумма, счёт-источник → счёт
/// зачисления (пикеры при неразрешённых счетах), оба приложения-источника.
/// Действия: «Подтвердить» (одна transfer-транзакция), «Расклеить»
/// (две одиночные карточки + blocklist), «Игнорировать» (обе половинки).
class _TransferPairBody extends ConsumerStatefulWidget {
const _TransferPairBody({
required this.message,
required this.userId,
required this.bundle,
});
final RawMessage message;
final String userId;
final DraftBundle bundle;
@override
ConsumerState<_TransferPairBody> createState() => _TransferPairBodyState();
}
class _TransferPairBodyState extends ConsumerState<_TransferPairBody> {
String? _fromId;
String? _toId;
@override
void initState() {
super.initState();
_fromId = widget.bundle.draft.accountId;
_toId = widget.bundle.draft.transferToAccountId;
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final draft = widget.bundle.draft;
final secondaryId = widget.bundle.pairedRawMessageId!;
final accounts =
ref.watch(accountsStreamProvider(widget.userId)).value ?? const [];
final accountName = {for (final a in accounts) a.id: a.name};
final primaryApp = ref
.watch(sourceAppLabelProvider(widget.userId, widget.message.packageName))
.value;
final secondary = ref.watch(pairedRawMessageProvider(secondaryId)).value;
final secondaryApp = secondary == null
? null
: ref
.watch(sourceAppLabelProvider(widget.userId, secondary.packageName))
.value;
final appsLine = [primaryApp, secondaryApp].nonNulls.join(' → ');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.swap_horiz, size: 18, color: p.accent),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.inboxTransferPairTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: p.ink,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
MoneyText(draft.amount,
color: p.ink, fontSize: 15, fontWeight: FontWeight.w700),
],
),
if (appsLine.isNotEmpty) ...[
const SizedBox(height: 4),
Text(appsLine,
style: TextStyle(fontSize: 13, color: p.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _AccountSlot(
label: l10n.inboxTransferFrom,
name: _fromId != null ? accountName[_fromId] : null,
onTap: () => _pickAccount(isFrom: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(Icons.arrow_forward, size: 16, color: p.ink2),
),
Expanded(
child: _AccountSlot(
label: l10n.inboxTransferTo,
name: _toId != null ? accountName[_toId] : null,
onTap: () => _pickAccount(isFrom: false),
),
),
],
),
const SizedBox(height: 8),
Text(
widget.message.body,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: p.accent),
onPressed: _confirm,
child: Text(l10n.inboxConfirm),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _SecondaryButton(
icon: Icons.link_off,
label: l10n.inboxUnpair,
onTap: () => _runReporting(
context,
() => ref.read(inboxControllerProvider.notifier).unpair(
userId: widget.userId,
message: widget.message,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: _SecondaryButton(
icon: Icons.close,
label: l10n.inboxIgnore,
onTap: () => ref
.read(inboxControllerProvider.notifier)
.ignore(widget.message),
),
),
],
),
],
);
}
Future<void> _pickAccount({required bool isFrom}) async {
final id = await showAccountPicker(context, userId: widget.userId);
if (id == null || !mounted) return;
setState(() => isFrom ? _fromId = id : _toId = id);
}
/// «Подтвердить»: недостающие счета запрашиваются пикером по тапу
/// (кнопку не дизейблим), затем создаётся одна transfer-транзакция.
Future<void> _confirm() async {
var from = _fromId;
if (from == null) {
from = await showAccountPicker(context, userId: widget.userId);
if (from == null || !mounted) return;
setState(() => _fromId = from);
}
var to = _toId;
if (to == null) {
to = await showAccountPicker(context, userId: widget.userId);
if (to == null || !mounted) return;
setState(() => _toId = to);
}
if (from == to) return; // перевод на тот же счёт не имеет смысла
await _runReporting(
context,
() => ref.read(inboxControllerProvider.notifier).confirmPair(
userId: widget.userId,
message: widget.message,
draft: widget.bundle.draft,
secondaryId: widget.bundle.pairedRawMessageId!,
fromAccountId: from!,
toAccountId: to!,
),
);
}
}
/// Слот счёта в merged-карточке: метка + имя счёта (или «выбрать»).
class _AccountSlot extends StatelessWidget {
const _AccountSlot({
required this.label,
required this.name,
required this.onTap,
});
final String label;
final String? name;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: p.paper,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: p.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: p.ink2)),
const SizedBox(height: 2),
Text(
name ?? context.l10n.inboxTransferPickAccount,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: name != null ? p.ink : p.accent,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
}
/// Пилюля быстрого выбора категории (частые категории приложения).
class _CategoryChip extends StatelessWidget {
const _CategoryChip({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected ? p.accentSoft : p.paper,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: selected ? p.accent : p.line),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: selected ? p.accent : p.ink,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}
}
class _UnrecognizedBody extends ConsumerWidget {
const _UnrecognizedBody({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: [
Text(l10n.inboxUnrecognized,
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: p.ink)),
const SizedBox(height: 6),
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.edit_outlined,
label: l10n.inboxAddManually,
onTap: () => context.push(AppRoutes.transactionNew),
),
),
const SizedBox(width: 8),
Expanded(
child: _SecondaryButton(
icon: Icons.close,
label: l10n.inboxIgnore,
onTap: () =>
ref.read(inboxControllerProvider.notifier).ignore(message),
),
),
],
),
],
);
}
}
/// Карточка сообщения, упавшего при парсинге (§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(parseErrorLabel(context, 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),
),
),
],
),
],
);
}
}
/// Карточка сообщения, ждущего сети для AI-разбора (`pendingAi`, §7): причина
/// зависания (офлайн vs сетевой сбой при живой сети), счётчик попыток, сырой
/// `body` и кнопки «Попробовать снова» / «Игнорировать». Ручной ретрай через
/// `InboxController.retry` сбрасывает счётчик попыток (`resetForRetry`).
class _PendingAiBody extends ConsumerWidget {
const _PendingAiBody({required this.message});
final RawMessage message;
/// Кэп реальных AI-попыток в pipeline (§7).
static const _maxAttempts = 5;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
// Живое состояние сети объясняет, ЧЕМУ сообщение висит: явный офлайн —
// ждём сеть; иначе сеть числится живой, но запрос не прошёл.
final offline = ref.watch(isOnlineProvider).value == false;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
offline ? Icons.cloud_off_outlined : Icons.hourglass_top,
size: 18,
color: p.ink2,
),
const SizedBox(width: 6),
Expanded(
child: Text(l10n.inboxWaitingNetworkTitle,
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: p.ink)),
),
],
),
const SizedBox(height: 6),
Text(
offline ? l10n.inboxStuckOffline : l10n.inboxStuckNetwork,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
),
if (message.parseAttemptCount > 0) ...[
const SizedBox(height: 4),
Text(
l10n.parsingDetailAttempt(message.parseAttemptCount, _maxAttempts),
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
),
],
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),
),
),
],
),
],
);
}
}
/// Акцентная сплит-кнопка «действие | ✎»: основной сегмент выполняет действие
/// ([onTap] == null — сегмент неактивен и приглушён), карандаш всегда активен
/// и открывает редактор/форму. Используется для «Создать правило» и
/// «Подтвердить».
class _SplitActionButton extends StatelessWidget {
const _SplitActionButton({
required this.label,
required this.onTap,
required this.onEdit,
required this.editTooltip,
});
final String label;
final VoidCallback? onTap;
final VoidCallback onEdit;
final String editTooltip;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Material(
color: p.accent,
borderRadius: BorderRadius.circular(12),
child: Row(
children: [
Expanded(
child: InkWell(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
onTap: onTap,
child: Opacity(
opacity: onTap == null ? 0.55 : 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
Container(width: 1, height: 28, color: Colors.white24),
InkWell(
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
onTap: onEdit,
child: Tooltip(
message: editTooltip,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Icon(Icons.edit_outlined, size: 18, color: Colors.white),
),
),
),
],
),
);
}
}
class _SecondaryButton extends StatelessWidget {
const _SecondaryButton({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return OutlinedButton.icon(
onPressed: onTap,
style: OutlinedButton.styleFrom(
foregroundColor: p.ink,
side: BorderSide(color: p.line),
padding: const EdgeInsets.symmetric(vertical: 10),
),
icon: Icon(icon, size: 16),
label: Text(label,
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis),
);
}
}