Files
fin-tracker/app/lib/features/pending/pending_page.dart
T
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

308 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../imports/data/imports_api.dart' show importErrorMessage;
import '../imports/providers.dart' show invalidateLedgerDependents;
import '../portfolio/labels.dart' show assetClassLabel, formatQty;
import 'data/pending_api.dart';
import 'providers.dart';
import 'widgets/create_instrument_dialog.dart';
import 'widgets/link_instrument_dialog.dart';
/// Нераспознанные инструменты: the queue of report lines whose instrument the server
/// refused to guess. Every row waits for an explicit decision — link, create or ignore.
class PendingInstrumentsPage extends ConsumerStatefulWidget {
const PendingInstrumentsPage({super.key});
@override
ConsumerState<PendingInstrumentsPage> createState() =>
_PendingInstrumentsPageState();
}
class _PendingInstrumentsPageState
extends ConsumerState<PendingInstrumentsPage> {
int? _busyId;
void _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _run(
int id,
Future<PendingResolveResult> Function() action,
) async {
setState(() => _busyId = id);
try {
final result = await action();
if (!mounted) return;
ref.invalidate(pendingInstrumentsProvider);
ref.invalidate(pendingCountProvider);
// Events moved out of `pending`, so holdings, allocation and the event list changed.
invalidateLedgerDependents(ref);
_snack(
result.status == 'ignored'
? 'Строка помечена как «не инструмент»'
: 'Привязано событий: ${result.eventsBound}'
'${result.aliasCreated ? ', добавлен алиас' : ''}',
);
} catch (e) {
if (!mounted) return;
_snack(importErrorMessage(e));
} finally {
if (mounted) setState(() => _busyId = null);
}
}
Future<void> _link(PendingInstrument p) async {
final instrumentId = await showDialog<int>(
context: context,
builder: (_) => LinkInstrumentDialog(
initialQuery: p.isin ?? p.ticker ?? p.name ?? '',
),
);
if (instrumentId == null || !mounted) return;
await _run(
p.id,
() => ref.read(pendingApiProvider).link(p.id, instrumentId),
);
}
Future<void> _create(PendingInstrument p) async {
final instrument = await showDialog<NewInstrument>(
context: context,
builder: (_) => CreateInstrumentDialog(pending: p),
);
if (instrument == null || !mounted) return;
await _run(
p.id,
() => ref.read(pendingApiProvider).create(p.id, instrument),
);
}
Future<void> _ignore(PendingInstrument p) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Игнорировать строку?'),
content: Text(
'«${p.title}» больше не будет предлагаться к резолву. '
'Её события останутся без инструмента.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Игнорировать'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _run(p.id, () => ref.read(pendingApiProvider).ignore(p.id));
}
@override
Widget build(BuildContext context) {
final rows = ref.watch(pendingInstrumentsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Нераспознанные инструменты'),
actions: [
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () {
ref.invalidate(pendingInstrumentsProvider);
ref.invalidate(pendingCountProvider);
},
),
],
),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(pendingInstrumentsProvider),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const _StatusFilter(),
const SizedBox(height: 8),
AsyncValueView(
value: rows,
onRetry: () => ref.invalidate(pendingInstrumentsProvider),
data: (items) => items.isEmpty
? const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.check_circle_outline,
message: 'Нераспознанных инструментов нет.',
),
)
: Column(
children: [
for (final p in items)
_PendingCard(
pending: p,
busy: _busyId == p.id,
onLink: () => _link(p),
onCreate: () => _create(p),
onIgnore: () => _ignore(p),
),
],
),
),
],
),
),
);
}
}
class _StatusFilter extends ConsumerWidget {
const _StatusFilter();
static const _options = {
'pending': 'Ждут решения',
'resolved': 'Привязаны',
'ignored': 'Игнорируются',
'all': 'Все',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(pendingStatusFilterProvider);
return Wrap(
spacing: 8,
children: [
for (final e in _options.entries)
ChoiceChip(
label: Text(e.value),
selected: current == e.key,
onSelected: (_) =>
ref.read(pendingStatusFilterProvider.notifier).state = e.key,
),
],
);
}
}
class _PendingCard extends StatelessWidget {
const _PendingCard({
required this.pending,
required this.busy,
required this.onLink,
required this.onCreate,
required this.onIgnore,
});
final PendingInstrument pending;
final bool busy;
final VoidCallback onLink;
final VoidCallback onCreate;
final VoidCallback onIgnore;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final p = pending;
final open = p.status == 'pending';
final facts = <String>[
if (p.isin != null) 'ISIN ${p.isin}',
if (p.ticker != null) 'тикер ${p.ticker}',
if (p.board != null) 'доска ${p.board}',
if (p.currency != null) p.currency!,
if (p.assetClassHint != null)
'в отчёте: ${assetClassLabel(p.assetClassHint)}',
];
final sample = <String>[
'встречается ${p.occurrences} раз',
if (p.sampleQuantity != null) 'кол-во ${formatQty(p.sampleQuantity!)}',
if (p.samplePrice != null)
'цена ${MoneyText.format(p.samplePrice!, p.currency ?? 'RUB')}',
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(p.title, style: theme.textTheme.titleMedium),
),
if (!open)
Chip(
visualDensity: VisualDensity.compact,
label: Text(
p.status == 'ignored' ? 'игнорируется' : 'привязан',
),
),
],
),
if (facts.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
facts.join(' · '),
style: theme.textTheme.bodySmall,
),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(sample.join(' · '), style: theme.textTheme.bodySmall),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'источник ${p.source} · ключ ${p.sourceKey}'
'${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
),
if (open) ...[
const SizedBox(height: 12),
if (busy)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
)
else
Wrap(
spacing: 12,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: onLink,
icon: const Icon(Icons.link, size: 18),
label: const Text('Привязать к существующему'),
),
OutlinedButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add, size: 18),
label: const Text('Создать новый'),
),
TextButton.icon(
onPressed: onIgnore,
icon: const Icon(Icons.block, size: 18),
label: const Text('Игнорировать'),
),
],
),
],
],
),
),
);
}
}