import 'dart:async'; import 'package:dio/dio.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/api/api_client.dart'; import '../../core/auth/auth_controller.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/help_tip.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; import '../accounts/providers.dart'; import '../portfolio/labels.dart' show formatQty, signColor; import 'event_row.dart'; import 'labels.dart'; import 'manual_event_dialog.dart'; import 'providers.dart'; /// События: the broker ledger with filters and infinite scroll — the screen that answers /// "where did this number come from" for a position or a reconciliation finding. class EventsPage extends ConsumerStatefulWidget { const EventsPage({super.key}); @override ConsumerState createState() => _EventsPageState(); } class _EventsPageState extends ConsumerState { final _searchController = TextEditingController(); final _scrollController = ScrollController(); Timer? _debounce; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _debounce?.cancel(); _searchController.dispose(); _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } void _onScroll() { if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) { ref.read(eventsControllerProvider.notifier).loadMore(); } } void _onSearchChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { ref .read(eventsControllerProvider.notifier) .setFilter( (f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), ); }); } Future _pickDateRange() async { final filter = ref.read(eventsControllerProvider.notifier).filter; final now = DateTime.now(); final picked = await showDateRangePicker( context: context, firstDate: DateTime.utc(2015), lastDate: now, initialDateRange: filter.from != null && filter.to != null ? DateTimeRange(start: filter.from!, end: filter.to!) : null, helpText: 'Период', ); if (picked != null) { ref .read(eventsControllerProvider.notifier) .setFilter( (f) => f.copyWith(from: () => picked.start, to: () => picked.end), ); } } void _clearDateRange() { ref .read(eventsControllerProvider.notifier) .setFilter((f) => f.copyWith(from: () => null, to: () => null)); } void _snack(String message) => ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(message))); /// Lots and every valuation are rebuilt from the ledger by the metrics refresh, so a change /// to the ledger asks for one. Best effort: the event itself is already saved. Future _queueMetricsRefresh() async { try { await ref.read(apiProvider).getMetricsApi().metricsRefresh(); } on DioException { // the next scheduled or manual refresh picks the change up } } Future _addManual() async { final body = await showManualEventDialog(context); if (body == null || !mounted) return; try { await ref .read(apiProvider) .getEventsApi() .eventsCreate(manualEventCreate: body); await ref.read(eventsControllerProvider.notifier).refresh(); await _queueMetricsRefresh(); if (mounted) _snack('Событие добавлено, метрики пересчитываются'); } on DioException catch (e) { if (mounted) _snack(problemMessage(e)); } } Future _deleteManual(EventOut e) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Удалить событие?'), content: Text( '«${eventTitle(e.kind, e.ticker)}» от ${ruDate(e.tradeDate)} будет удалено.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Удалить'), ), ], ), ); if (confirmed != true || !mounted) return; try { await ref.read(apiProvider).getEventsApi().eventsDelete(eventId: e.id); await ref.read(eventsControllerProvider.notifier).refresh(); await _queueMetricsRefresh(); if (mounted) _snack('Событие удалено, метрики пересчитываются'); } on DioException catch (err) { if (mounted) _snack(problemMessage(err)); } } void _showDetail(EventOut e) { final accountNames = ref.read(accountNamesProvider); showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) => _EventDetailSheet( event: e, accountNames: accountNames, onDelete: e.source_ == 'manual' ? () { Navigator.of(sheetContext).pop(); _deleteManual(e); } : null, ), ); } @override Widget build(BuildContext context) { final state = ref.watch(eventsControllerProvider); final filter = ref.watch(eventsControllerProvider.notifier).filter; final accountNames = ref.watch(accountNamesProvider); final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const []; return Scaffold( floatingActionButton: FloatingActionButton.extended( onPressed: _addManual, icon: const Icon(Icons.add), label: const Text('Событие'), ), appBar: AppBar( title: const Text('События'), actions: [ if (state.total > 0) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Center( child: Text('${state.items.length} из ${state.total}'), ), ), ], ), body: Column( children: [ _Filters( filter: filter, accounts: accounts, searchController: _searchController, onSearchChanged: _onSearchChanged, onPickDateRange: _pickDateRange, onClearDateRange: _clearDateRange, ), const Divider(height: 1), Expanded(child: _buildList(state, accountNames)), ], ), ); } Widget _buildList(EventsState state, Map accountNames) { if (state.loading && state.items.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (state.error != null && state.items.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32, ), const SizedBox(height: 8), Text('${state.error}', textAlign: TextAlign.center), const SizedBox(height: 12), FilledButton.tonal( onPressed: () => ref.read(eventsControllerProvider.notifier).refresh(), child: const Text('Повторить'), ), ], ), ), ); } if (state.items.isEmpty) { return const EmptyState( icon: Icons.receipt_long_outlined, message: 'Событий не найдено — измените фильтры или выполните синхронизацию брокера.', ); } return RefreshIndicator( onRefresh: () => ref.read(eventsControllerProvider.notifier).refresh(), child: ListView.builder( controller: _scrollController, itemCount: state.items.length + 1, itemBuilder: (context, index) { if (index == state.items.length) { if (!state.hasMore) return const SizedBox(height: 24); return Padding( padding: const EdgeInsets.all(16), child: Center( child: state.loadingMore ? const CircularProgressIndicator() : TextButton( onPressed: () => ref .read(eventsControllerProvider.notifier) .loadMore(), child: const Text('Ещё'), ), ), ); } final e = state.items[index]; return EventRow( event: e, accountName: accountNames[e.accountId], onTap: () => _showDetail(e), ); }, ), ); } } /// The filter bar. `instrument_id` is left out on purpose — there is no instrument picker /// on this screen, and the search box already matches a ticker. class _Filters extends ConsumerWidget { const _Filters({ required this.filter, required this.accounts, required this.searchController, required this.onSearchChanged, required this.onPickDateRange, required this.onClearDateRange, }); final EventsFilter filter; final List accounts; final TextEditingController searchController; final ValueChanged onSearchChanged; final VoidCallback onPickDateRange; final VoidCallback onClearDateRange; @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(eventsControllerProvider.notifier); return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: searchController, decoration: const InputDecoration( prefixIcon: Icon(Icons.search), hintText: 'Поиск по описанию или тикеру', isDense: true, border: OutlineInputBorder(), ), onChanged: onSearchChanged, ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ InputChip( avatar: const Icon(Icons.date_range, size: 18), label: Text( filter.from != null && filter.to != null ? '${ruDate(filter.from!)} – ${ruDate(filter.to!)}' : 'Период', ), onPressed: onPickDateRange, onDeleted: filter.from != null ? onClearDateRange : null, ), SizedBox( width: 180, child: DropdownButtonFormField( initialValue: filter.accountId, decoration: const InputDecoration(labelText: 'Счёт'), items: [ const DropdownMenuItem( value: null, child: Text('Все счета'), ), for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)), ], onChanged: (v) => notifier.setFilter((f) => f.copyWith(accountId: () => v)), ), ), SizedBox( width: 180, child: DropdownButtonFormField( initialValue: filter.kind, decoration: const InputDecoration(labelText: 'Тип'), items: [ const DropdownMenuItem( value: null, child: Text('Все типы'), ), for (final k in filterableEventKinds) DropdownMenuItem( value: k, child: Text(eventKindLabel(k)), ), ], onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)), ), ), SizedBox( width: 180, child: DropdownButtonFormField( initialValue: filter.status, decoration: const InputDecoration(labelText: 'Статус'), items: [ const DropdownMenuItem( value: null, child: Text('Любой статус'), ), for (final s in EventStatus.values) if (s != EventStatus.unknownDefaultOpenApi) DropdownMenuItem( value: s, child: Text(eventStatusLabel(s)), ), ], onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)), ), ), FilterChip( label: const Text('Внешние потоки'), tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR', selected: filter.externalFlow == true, onSelected: (on) => notifier.setFilter( (f) => f.copyWith(externalFlow: () => on ? true : null), ), ), ], ), ], ), ); } } class _EventDetailSheet extends StatelessWidget { const _EventDetailSheet({ required this.event, required this.accountNames, this.onDelete, }); final EventOut event; final Map accountNames; /// Set only for events entered by hand: broker events come back with the next sync. final VoidCallback? onDelete; @override Widget build(BuildContext context) { final e = event; final theme = Theme.of(context); String money(String? v, String currency) => v == null ? '—' : MoneyText.format(v, currency); final rows = <(String, String)>[ ('Дата сделки', ruDate(e.tradeDate)), ( 'Время', '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:' '${e.ts.minute.toString().padLeft(2, '0')}', ), ('Тип', eventKindLabel(e.kind)), ('Статус', eventStatusLabel(e.status)), ('Источник', eventSourceLabel(e.source_)), ('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'), if (e.ticker != null) ('Инструмент', e.ticker!), if (e.quantity != null) ('Количество', formatQty(e.quantity!)), if (e.price != null) ('Цена', money(e.price, e.priceCurrency ?? e.currency)), ('Сумма', money(e.amount, e.currency)), ('Сумма, ₽', money(e.amountRub, 'RUB')), if (e.fee != null) ('Комиссия', money(e.fee, e.currency)), if (e.tax != null) ('Налог', money(e.tax, e.currency)), if (e.accruedInterest != null) ('НКД', money(e.accruedInterest, e.currency)), ('Внешний поток', e.externalFlow ? 'да' : 'нет'), if (e.description != null) ('Описание', e.description!), ]; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge, ), ), Text( MoneyText.format(e.amount, e.currency), style: theme.textTheme.titleMedium?.copyWith( color: signColor(context, e.amount), ), ), ], ), const SizedBox(height: 12), for (final (label, value) in rows) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 140, child: TermLabel(label, style: theme.textTheme.bodySmall), ), Expanded(child: Text(value)), ], ), ), if (onDelete != null) ...[ const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: onDelete, icon: const Icon(Icons.delete_outline, size: 18), label: const Text('Удалить'), ), ), ], if (e.instrumentId != null) ...[ const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: FilledButton.tonalIcon( onPressed: () { Navigator.of(context).pop(); context.go('/portfolio/instrument/${e.instrumentId}'); }, icon: const Icon(Icons.open_in_new, size: 18), label: const Text('К инструменту'), ), ), ], ], ), ), ); } }