- 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>
1169 lines
39 KiB
Dart
1169 lines
39 KiB
Dart
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),
|
||
);
|
||
}
|
||
}
|