feat(app): экран «События»

Лента брокерского леджера: фильтры по счёту, типу, датам и поиск, бесконечная
прокрутка с явной кнопкой «Ещё», карточка события в bottom sheet с переходом на
инструмент. Паттерн контроллера и пагинации взят у транзакций один в один — это тот
же список с фильтрами, и второй способ делать одно и то же был бы просто вторым
способом его чинить.

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

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

Бэкенд не менялся — /events с фильтрами и пагинацией закрывает экран целиком,
клиент не перегенерировался.
This commit is contained in:
Dmitry
2026-09-18 15:08:12 +03:00
parent 885137df4f
commit c588b575a7
7 changed files with 773 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/money_text.dart';
import '../portfolio/labels.dart' show formatQty, signColor;
import 'labels.dart';
/// One row of the События list: kind and ticker, the date with the account and quantity
/// behind it, and the native amount with its RUB equivalent when the currency differs.
///
/// The RUB line can be missing while the native amount is there — that is a day with no
/// rate, not a zero — so it is simply left out rather than filled in.
class EventRow extends StatelessWidget {
const EventRow({
required this.event,
required this.accountName,
required this.onTap,
super.key,
});
final EventOut event;
final String? accountName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final e = event;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final (icon, color) = eventKindIcon(e.kind, scheme);
final showRub = e.currency != 'RUB' && e.amountRub != null;
final subtitle = [
ruDate(e.tradeDate),
?accountName,
if (e.quantity != null) '${formatQty(e.quantity!)} шт.',
if (e.status != EventStatus.confirmed) eventStatusLabel(e.status),
].join(' · ');
return ListTile(
onTap: onTap,
leading: Icon(icon, color: color),
title: Row(
children: [
Flexible(child: Text(eventTitle(e.kind, e.ticker), overflow: TextOverflow.ellipsis)),
if (e.externalFlow) ...[
const SizedBox(width: 6),
Tooltip(
message: 'Внешний поток — учитывается в XIRR',
child: Icon(Icons.swap_vert, size: 14, color: scheme.outline),
),
],
],
),
subtitle: Text(subtitle),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MoneyText(
e.amount,
currency: e.currency,
style: TextStyle(color: signColor(context, e.amount)),
),
if (showRub)
Text(
MoneyText.format(e.amountRub!, 'RUB'),
style: theme.textTheme.bodySmall?.copyWith(color: scheme.outline),
),
],
),
);
}
}
+385
View File
@@ -0,0 +1,385 @@
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('К инструменту'),
),
),
],
],
),
),
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import '../portfolio/labels.dart' show eventKindLabel;
export '../portfolio/labels.dart' show eventKindLabel, eventKindLabels;
/// The kinds worth offering as a filter, in the order a person scans for them: the two
/// trades first, then the payouts, then the money moving in and out. The wire enum has
/// more members (splits, amortization, the generated `unknown_default_open_api`), but a
/// dropdown listing every one of them is a worse filter than a short one.
const filterableEventKinds = [
EventKind.buy,
EventKind.sell,
EventKind.dividend,
EventKind.coupon,
EventKind.interest,
EventKind.commission,
EventKind.tax,
EventKind.deposit,
EventKind.withdrawal,
EventKind.transferIn,
EventKind.transferOut,
EventKind.fxExchange,
EventKind.repayment,
EventKind.other,
];
/// Statuses as a person reads them. Only `confirmed` feeds analytics, so which of these a
/// row carries is the answer to "why isn't this event in my numbers".
const eventStatusLabels = {
'confirmed': 'Подтверждено',
'pending': 'Ожидает',
'shadow': 'Теневое',
'ignored': 'Игнорируется',
};
String eventStatusLabel(EventStatus status) => eventStatusLabels[status.value] ?? status.value;
/// Icon and colour per kind, on the same principle as the transaction list: money coming
/// in is green, money leaving is the error colour, everything structural is neutral.
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) => switch (kind) {
EventKind.buy => (Icons.add_shopping_cart, scheme.primary),
EventKind.sell => (Icons.sell_outlined, Colors.deepPurple),
EventKind.dividend || EventKind.coupon || EventKind.interest => (
Icons.payments_outlined,
Colors.green,
),
EventKind.taxRefund => (Icons.assignment_return_outlined, Colors.green),
EventKind.tax || EventKind.commission => (Icons.receipt_outlined, scheme.error),
EventKind.deposit => (Icons.arrow_circle_down_outlined, Colors.green),
EventKind.withdrawal => (Icons.arrow_circle_up_outlined, scheme.error),
EventKind.transferIn || EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
EventKind.fxExchange => (Icons.currency_exchange, Colors.amber),
EventKind.split || EventKind.amortization || EventKind.repayment => (
Icons.call_split,
scheme.outline,
),
EventKind.other || EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
};
/// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind
/// them (deposits, fees) keep just the kind.
String eventTitle(EventKind kind, String? ticker) =>
ticker == null || ticker.isEmpty ? eventKindLabel(kind) : '${eventKindLabel(kind)} · $ticker';
+163
View File
@@ -0,0 +1,163 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
/// What the События screen asks the ledger for. `instrumentId` has no control of its own —
/// the instrument card already lists an instrument's own events — but it is carried here
/// so navigating in with a preset instrument stays a one-line change.
class EventsFilter {
const EventsFilter({
this.q,
this.from,
this.to,
this.accountId,
this.instrumentId,
this.kind,
this.status,
this.externalFlow,
});
final String? q;
final DateTime? from;
final DateTime? to;
final int? accountId;
final int? instrumentId;
final EventKind? kind;
final EventStatus? status;
final bool? externalFlow;
EventsFilter copyWith({
String? Function()? q,
DateTime? Function()? from,
DateTime? Function()? to,
int? Function()? accountId,
int? Function()? instrumentId,
EventKind? Function()? kind,
EventStatus? Function()? status,
bool? Function()? externalFlow,
}) {
return EventsFilter(
q: q != null ? q() : this.q,
from: from != null ? from() : this.from,
to: to != null ? to() : this.to,
accountId: accountId != null ? accountId() : this.accountId,
instrumentId: instrumentId != null ? instrumentId() : this.instrumentId,
kind: kind != null ? kind() : this.kind,
status: status != null ? status() : this.status,
externalFlow: externalFlow != null ? externalFlow() : this.externalFlow,
);
}
}
class EventsState {
const EventsState({
this.items = const [],
this.page = 1,
this.pageSize = 50,
this.total = 0,
this.loading = false,
this.loadingMore = false,
this.error,
});
final List<EventOut> items;
final int page;
final int pageSize;
final int total;
final bool loading;
final bool loadingMore;
final Object? error;
bool get hasMore => items.length < total;
EventsState copyWith({
List<EventOut>? items,
int? page,
int? pageSize,
int? total,
bool? loading,
bool? loadingMore,
Object? error,
bool clearError = false,
}) {
return EventsState(
items: items ?? this.items,
page: page ?? this.page,
pageSize: pageSize ?? this.pageSize,
total: total ?? this.total,
loading: loading ?? this.loading,
loadingMore: loadingMore ?? this.loadingMore,
error: clearError ? null : (error ?? this.error),
);
}
}
/// Filters + paginated results for События, the same shape Операции uses: `refresh()`
/// restarts from page 1 (on build and on every filter change), `loadMore()` appends the
/// next page for infinite scroll.
class EventsController extends Notifier<EventsState> {
EventsFilter filter = const EventsFilter();
@override
EventsState build() {
Future.microtask(refresh);
return const EventsState(loading: true);
}
Future<void> setFilter(EventsFilter Function(EventsFilter) update) {
filter = update(filter);
return refresh();
}
Future<EventPage> _fetch(int page) async {
final r = await ref.read(apiProvider).getEventsApi().eventsList(
from: filter.from,
to: filter.to,
accountId: filter.accountId,
instrumentId: filter.instrumentId,
kind: filter.kind,
status: filter.status,
externalFlow: filter.externalFlow,
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
page: page,
pageSize: state.pageSize,
);
return r.data!;
}
Future<void> refresh() async {
state = state.copyWith(loading: true, clearError: true);
try {
final page = await _fetch(1);
state = EventsState(
items: page.items,
page: page.page,
pageSize: page.pageSize,
total: page.total,
);
} on DioException catch (e) {
state = state.copyWith(loading: false, error: e);
}
}
Future<void> loadMore() async {
if (state.loading || state.loadingMore || !state.hasMore) return;
state = state.copyWith(loadingMore: true, clearError: true);
try {
final page = await _fetch(state.page + 1);
state = state.copyWith(
items: [...state.items, ...page.items],
page: page.page,
total: page.total,
loadingMore: false,
);
} on DioException catch (e) {
state = state.copyWith(loadingMore: false, error: e);
}
}
}
final eventsControllerProvider =
NotifierProvider<EventsController, EventsState>(EventsController.new);
+1
View File
@@ -13,6 +13,7 @@ const _destinations = [
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
_Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель'),
_Destination('/events', Icons.event_note_outlined, Icons.event_note, 'События'),
_Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
_Destination(