Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
527 lines
18 KiB
Dart
527 lines
18 KiB
Dart
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('К инструменту'),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|