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 createState() => _PendingInstrumentsPageState(); } class _PendingInstrumentsPageState extends ConsumerState { int? _busyId; void _snack(String message) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); Future _run(int id, Future 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 _link(PendingInstrument p) async { final instrumentId = await showDialog( 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 _create(PendingInstrument p) async { final instrument = await showDialog( context: context, builder: (_) => CreateInstrumentDialog(pending: p), ); if (instrument == null || !mounted) return; await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument)); } Future _ignore(PendingInstrument p) async { final confirmed = await showDialog( 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 = [ 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 = [ 'встречается ${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('Игнорировать'), ), ], ), ], ], ), ), ); } }