feat(app): экран «События»
Лента брокерского леджера: фильтры по счёту, типу, датам и поиск, бесконечная прокрутка с явной кнопкой «Ещё», карточка события в bottom sheet с переходом на инструмент. Паттерн контроллера и пагинации взят у транзакций один в один — это тот же список с фильтрами, и второй способ делать одно и то же был бы просто вторым способом его чинить. Пункт навигации стоит между «Портфелем» и «Потоками», а не рядом с «Операциями»: это инвестиционная лента, а в операциях лежит ZenMoney. eventKindLabels переиспользован из portfolio/labels.dart, где он уже жил ради карточки инструмента, а не скопирован: два словаря подписей для одного енума разъезжаются на первом же новом типе события. Бэкенд не менялся — /events с фильтрами и пагинацией закрывает экран целиком, клиент не перегенерировался.
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('К инструменту'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user