feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user