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