import 'dart:async'; 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/utils/ru_date.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 '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 _showDetail(EventOut e) { final accountNames = ref.read(accountNamesProvider); showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames), ); } @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( 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, isDense: true, decoration: const InputDecoration(labelText: 'Счёт', isDense: true), 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, isDense: true, decoration: const InputDecoration(labelText: 'Тип', isDense: true), 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, isDense: true, decoration: const InputDecoration(labelText: 'Статус', isDense: true), 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}); final EventOut event; final Map accountNames; @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)), ('Счёт', 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: Text(label, style: theme.textTheme.bodySmall), ), Expanded(child: Text(value)), ], ), ), 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('К инструменту'), ), ), ], ], ), ), ); } }