Files
fin-tracker/app/lib/features/events/events_page.dart
T
Dmitry c588b575a7 feat(app): экран «События»
Лента брокерского леджера: фильтры по счёту, типу, датам и поиск, бесконечная
прокрутка с явной кнопкой «Ещё», карточка события в bottom sheet с переходом на
инструмент. Паттерн контроллера и пагинации взят у транзакций один в один — это тот
же список с фильтрами, и второй способ делать одно и то же был бы просто вторым
способом его чинить.

Пункт навигации стоит между «Портфелем» и «Потоками», а не рядом с «Операциями»:
это инвестиционная лента, а в операциях лежит ZenMoney.

eventKindLabels переиспользован из portfolio/labels.dart, где он уже жил ради карточки
инструмента, а не скопирован: два словаря подписей для одного енума разъезжаются на
первом же новом типе события.

Бэкенд не менялся — /events с фильтрами и пагинацией закрывает экран целиком,
клиент не перегенерировался.
2026-09-18 15:08:12 +03:00

386 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<EventsPage> createState() => _EventsPageState();
}
class _EventsPageState extends ConsumerState<EventsPage> {
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<void> _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 ?? const <AccountOut>[];
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<int, String> 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<AccountOut> accounts;
final TextEditingController searchController;
final ValueChanged<String> 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<int?>(
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<EventKind?>(
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<EventStatus?>(
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<int, String> 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('К инструменту'),
),
),
],
],
),
),
);
}
}