feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+180 -39
View File
@@ -1,17 +1,22 @@
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
@@ -44,7 +49,8 @@ class _EventsPageState extends ConsumerState<EventsPage> {
}
void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(eventsControllerProvider.notifier).loadMore();
}
}
@@ -52,8 +58,11 @@ class _EventsPageState extends ConsumerState<EventsPage> {
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()),
ref
.read(eventsControllerProvider.notifier)
.setFilter(
(f) =>
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
);
});
}
@@ -71,16 +80,79 @@ class _EventsPageState extends ConsumerState<EventsPage> {
helpText: 'Период',
);
if (picked != null) {
ref.read(eventsControllerProvider.notifier).setFilter(
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),
);
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) {
@@ -88,7 +160,16 @@ class _EventsPageState extends ConsumerState<EventsPage> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames),
builder: (sheetContext) => _EventDetailSheet(
event: e,
accountNames: accountNames,
onDelete: e.source_ == 'manual'
? () {
Navigator.of(sheetContext).pop();
_deleteManual(e);
}
: null,
),
);
}
@@ -97,16 +178,24 @@ class _EventsPageState extends ConsumerState<EventsPage> {
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>[];
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}')),
child: Center(
child: Text('${state.items.length} из ${state.total}'),
),
),
],
),
@@ -138,12 +227,17 @@ class _EventsPageState extends ConsumerState<EventsPage> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
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(),
onPressed: () =>
ref.read(eventsControllerProvider.notifier).refresh(),
child: const Text('Повторить'),
),
],
@@ -171,7 +265,9 @@ class _EventsPageState extends ConsumerState<EventsPage> {
child: state.loadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(),
onPressed: () => ref
.read(eventsControllerProvider.notifier)
.loadMore(),
child: const Text('Ещё'),
),
),
@@ -246,51 +342,67 @@ class _Filters extends ConsumerWidget {
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: filter.accountId,
isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
decoration: const InputDecoration(labelText: 'Счёт'),
items: [
const DropdownMenuItem(value: null, child: Text('Все счета')),
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)),
onChanged: (v) =>
notifier.setFilter((f) => f.copyWith(accountId: () => v)),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<EventKind?>(
initialValue: filter.kind,
isDense: true,
decoration: const InputDecoration(labelText: 'Тип', isDense: true),
decoration: const InputDecoration(labelText: 'Тип'),
items: [
const DropdownMenuItem(value: null, child: Text('Все типы')),
const DropdownMenuItem(
value: null,
child: Text('Все типы'),
),
for (final k in filterableEventKinds)
DropdownMenuItem(value: k, child: Text(eventKindLabel(k))),
DropdownMenuItem(
value: k,
child: Text(eventKindLabel(k)),
),
],
onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)),
onChanged: (v) =>
notifier.setFilter((f) => f.copyWith(kind: () => v)),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<EventStatus?>(
initialValue: filter.status,
isDense: true,
decoration: const InputDecoration(labelText: 'Статус', isDense: true),
decoration: const InputDecoration(labelText: 'Статус'),
items: [
const DropdownMenuItem(value: null, child: Text('Любой статус')),
const DropdownMenuItem(
value: null,
child: Text('Любой статус'),
),
for (final s in EventStatus.values)
if (s != EventStatus.unknownDefaultOpenApi)
DropdownMenuItem(value: s, child: Text(eventStatusLabel(s))),
DropdownMenuItem(
value: s,
child: Text(eventStatusLabel(s)),
),
],
onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)),
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)),
onSelected: (on) => notifier.setFilter(
(f) => f.copyWith(externalFlow: () => on ? true : null),
),
),
],
),
@@ -301,31 +413,45 @@ class _Filters extends ConsumerWidget {
}
class _EventDetailSheet extends StatelessWidget {
const _EventDetailSheet({required this.event, required this.accountNames});
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);
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')}'),
(
'Время',
'${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)),
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)),
if (e.accruedInterest != null)
('НКД', money(e.accruedInterest, e.currency)),
('Внешний поток', e.externalFlow ? 'да' : 'нет'),
if (e.description != null) ('Описание', e.description!),
];
@@ -339,12 +465,16 @@ class _EventDetailSheet extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge),
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)),
style: theme.textTheme.titleMedium?.copyWith(
color: signColor(context, e.amount),
),
),
],
),
@@ -357,12 +487,23 @@ class _EventDetailSheet extends StatelessWidget {
children: [
SizedBox(
width: 140,
child: Text(label, style: theme.textTheme.bodySmall),
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(