Files
fin-tracker/app/lib/features/events/events_page.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

527 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:dio/dio.dart';
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/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/help_tip.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 'manual_event_dialog.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 _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
/// Lots and every valuation are rebuilt from the ledger by the metrics refresh, so a change
/// to the ledger asks for one. Best effort: the event itself is already saved.
Future<void> _queueMetricsRefresh() async {
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
}
Future<void> _addManual() async {
final body = await showManualEventDialog(context);
if (body == null || !mounted) return;
try {
await ref
.read(apiProvider)
.getEventsApi()
.eventsCreate(manualEventCreate: body);
await ref.read(eventsControllerProvider.notifier).refresh();
await _queueMetricsRefresh();
if (mounted) _snack('Событие добавлено, метрики пересчитываются');
} on DioException catch (e) {
if (mounted) _snack(problemMessage(e));
}
}
Future<void> _deleteManual(EventOut e) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить событие?'),
content: Text(
${eventTitle(e.kind, e.ticker)}» от ${ruDate(e.tradeDate)} будет удалено.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Удалить'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ref.read(apiProvider).getEventsApi().eventsDelete(eventId: e.id);
await ref.read(eventsControllerProvider.notifier).refresh();
await _queueMetricsRefresh();
if (mounted) _snack('Событие удалено, метрики пересчитываются');
} on DioException catch (err) {
if (mounted) _snack(problemMessage(err));
}
}
void _showDetail(EventOut e) {
final accountNames = ref.read(accountNamesProvider);
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) => _EventDetailSheet(
event: e,
accountNames: accountNames,
onDelete: e.source_ == 'manual'
? () {
Navigator.of(sheetContext).pop();
_deleteManual(e);
}
: null,
),
);
}
@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?.data ?? const <AccountOut>[];
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _addManual,
icon: const Icon(Icons.add),
label: const Text('Событие'),
),
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,
decoration: const InputDecoration(labelText: 'Счёт'),
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,
decoration: const InputDecoration(labelText: 'Тип'),
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,
decoration: const InputDecoration(labelText: 'Статус'),
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,
this.onDelete,
});
final EventOut event;
final Map<int, String> accountNames;
/// Set only for events entered by hand: broker events come back with the next sync.
final VoidCallback? onDelete;
@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)),
('Источник', eventSourceLabel(e.source_)),
('Счёт', 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: TermLabel(label, style: theme.textTheme.bodySmall),
),
Expanded(child: Text(value)),
],
),
),
if (onDelete != null) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Удалить'),
),
),
],
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('К инструменту'),
),
),
],
],
),
),
);
}
}