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
+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);