From c588b575a7362d81c83aed97f64cb522216a8c56 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 18 Sep 2026 15:08:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=C2=AB=D0=A1=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лента брокерского леджера: фильтры по счёту, типу, датам и поиск, бесконечная прокрутка с явной кнопкой «Ещё», карточка события в bottom sheet с переходом на инструмент. Паттерн контроллера и пагинации взят у транзакций один в один — это тот же список с фильтрами, и второй способ делать одно и то же был бы просто вторым способом его чинить. Пункт навигации стоит между «Портфелем» и «Потоками», а не рядом с «Операциями»: это инвестиционная лента, а в операциях лежит ZenMoney. eventKindLabels переиспользован из portfolio/labels.dart, где он уже жил ради карточки инструмента, а не скопирован: два словаря подписей для одного енума разъезжаются на первом же новом типе события. Бэкенд не менялся — /events с фильтрами и пагинацией закрывает экран целиком, клиент не перегенерировался. --- app/lib/features/events/event_row.dart | 74 +++++ app/lib/features/events/events_page.dart | 385 +++++++++++++++++++++++ app/lib/features/events/labels.dart | 65 ++++ app/lib/features/events/providers.dart | 163 ++++++++++ app/lib/features/shell/app_shell.dart | 1 + app/lib/router.dart | 2 + app/test/event_row_test.dart | 83 +++++ 7 files changed, 773 insertions(+) create mode 100644 app/lib/features/events/event_row.dart create mode 100644 app/lib/features/events/events_page.dart create mode 100644 app/lib/features/events/labels.dart create mode 100644 app/lib/features/events/providers.dart create mode 100644 app/test/event_row_test.dart diff --git a/app/lib/features/events/event_row.dart b/app/lib/features/events/event_row.dart new file mode 100644 index 0000000..6c295e7 --- /dev/null +++ b/app/lib/features/events/event_row.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/events/events_page.dart b/app/lib/features/events/events_page.dart new file mode 100644 index 0000000..e2e3e14 --- /dev/null +++ b/app/lib/features/events/events_page.dart @@ -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 createState() => _EventsPageState(); +} + +class _EventsPageState extends ConsumerState { + 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 _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 []; + + 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 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 accounts; + final TextEditingController searchController; + final ValueChanged 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( + 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( + 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( + 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 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('К инструменту'), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/features/events/labels.dart b/app/lib/features/events/labels.dart new file mode 100644 index 0000000..4af42ab --- /dev/null +++ b/app/lib/features/events/labels.dart @@ -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'; diff --git a/app/lib/features/events/providers.dart b/app/lib/features/events/providers.dart new file mode 100644 index 0000000..d743199 --- /dev/null +++ b/app/lib/features/events/providers.dart @@ -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 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? 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 { + EventsFilter filter = const EventsFilter(); + + @override + EventsState build() { + Future.microtask(refresh); + return const EventsState(loading: true); + } + + Future setFilter(EventsFilter Function(EventsFilter) update) { + filter = update(filter); + return refresh(); + } + + Future _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 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 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.new); diff --git a/app/lib/features/shell/app_shell.dart b/app/lib/features/shell/app_shell.dart index 50ed3ea..dd79079 100644 --- a/app/lib/features/shell/app_shell.dart +++ b/app/lib/features/shell/app_shell.dart @@ -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( diff --git a/app/lib/router.dart b/app/lib/router.dart index a4d4d63..9f2c4bc 100644 --- a/app/lib/router.dart +++ b/app/lib/router.dart @@ -6,6 +6,7 @@ import 'core/auth/auth_controller.dart'; import 'features/accounts/accounts_page.dart'; import 'features/cashflow/cashflow_page.dart'; import 'features/categories/categories_page.dart'; +import 'features/events/events_page.dart'; import 'features/home/home_page.dart'; import 'features/login/login_page.dart'; import 'features/portfolio/instrument_page.dart'; @@ -47,6 +48,7 @@ final routerProvider = Provider((ref) { builder: (_, state) => InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)), ), + GoRoute(path: '/events', builder: (_, _) => const EventsPage()), GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()), GoRoute( path: '/categories', diff --git a/app/test/event_row_test.dart b/app/test/event_row_test.dart new file mode 100644 index 0000000..8fcaa16 --- /dev/null +++ b/app/test/event_row_test.dart @@ -0,0 +1,83 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/widgets/money_text.dart'; +import 'package:fintracker_app/features/events/event_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +EventOut event({ + EventKind kind = EventKind.buy, + String currency = 'RUB', + String amount = '-10000', + String? amountRub = '-10000', + String? ticker = 'SBER', + String? quantity = '100', + EventStatus status = EventStatus.confirmed, + bool externalFlow = false, +}) { + return EventOut( + accountId: 46, + accruedInterest: null, + amount: amount, + amountRub: amountRub, + currency: currency, + description: 'Покупка ценных бумаг', + externalFlow: externalFlow, + fee: '30', + id: 1, + instrumentId: 311, + kind: kind, + price: '100', + priceCurrency: currency, + quantity: quantity, + status: status, + tax: null, + ticker: ticker, + tradeDate: DateTime(2026, 9, 10), + ts: DateTime(2026, 9, 10, 12, 30), + ); +} + +Widget wrap(EventOut e) => MaterialApp( + home: Scaffold( + body: EventRow(event: e, accountName: 'T-Invest ИИС', onTap: () {}), + ), + ); + +void main() { + testWidgets('shows kind, ticker, account and the native amount', (tester) async { + await tester.pumpWidget(wrap(event())); + + expect(find.text('Покупка · SBER'), findsOneWidget); + expect(find.textContaining('T-Invest ИИС'), findsOneWidget); + expect(find.textContaining('100 шт.'), findsOneWidget); + expect(find.text(MoneyText.format('-10000', 'RUB')), findsOneWidget); + }); + + testWidgets('adds the RUB equivalent only for a foreign currency', (tester) async { + await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500'))); + + expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); + expect(find.text(MoneyText.format('-9500', 'RUB')), findsOneWidget); + }); + + testWidgets('leaves the RUB line out when no rate was available', (tester) async { + await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: null))); + + expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); + expect(find.textContaining('₽'), findsNothing); + }); + + testWidgets('names a non-confirmed status in the subtitle', (tester) async { + await tester.pumpWidget(wrap(event(status: EventStatus.pending))); + + expect(find.textContaining('Ожидает'), findsOneWidget); + }); + + testWidgets('drops the separator for an event with no instrument', (tester) async { + await tester.pumpWidget( + wrap(event(kind: EventKind.deposit, ticker: null, quantity: null, externalFlow: true)), + ); + + expect(find.text('Пополнение'), findsOneWidget); + }); +}