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
+166 -50
View File
@@ -1,15 +1,21 @@
import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/asset_icon.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/stale_banner.dart';
import '../../core/widgets/help_tip.dart';
import 'instrument_edit_dialog.dart';
import 'labels.dart';
import 'providers.dart';
@@ -23,15 +29,51 @@ class InstrumentPage extends ConsumerWidget {
final int instrumentId;
Future<void> _edit(
BuildContext context,
WidgetRef ref,
InstrumentOut instrument,
) async {
final patch = await showDialog<InstrumentPatch>(
context: context,
builder: (_) => InstrumentEditDialog(instrument: instrument),
);
if (patch == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
final api = ref.read(apiProvider);
await api.getInstrumentsApi().instrumentsPatch(
instrumentId: instrumentId,
instrumentPatch: patch,
);
invalidatePortfolioProviders(ref);
try {
// the asset class feeds the allocation metrics; the lot only the live rebalancing
await api.getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
} on DioException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(instrumentProvider(instrumentId));
final instrument = detail.valueOrNull?.data.instrument;
return Scaffold(
appBar: AppBar(
title: Text(detail.valueOrNull?.data.instrument.ticker ??
detail.valueOrNull?.data.instrument.name ??
'Инструмент'),
title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'),
actions: [
if (instrument != null)
IconButton(
tooltip: 'Редактировать',
icon: const Icon(Icons.edit_outlined),
onPressed: () => _edit(context, ref, instrument),
),
],
),
body: AsyncValueView(
value: detail,
@@ -41,7 +83,8 @@ class InstrumentPage extends ConsumerWidget {
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!),
if (cached.fetchedAt != null)
StaleBanner(fetchedAt: cached.fetchedAt!),
_Header(instrument: d.instrument, holding: d.holding),
const SizedBox(height: 16),
_Section(
@@ -57,14 +100,20 @@ class InstrumentPage extends ConsumerWidget {
_Section(
title: 'Лоты',
child: d.lots.isEmpty
? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.')
? const EmptyState(
icon: Icons.layers_outlined,
message: 'Лотов нет.',
)
: _LotsTable(lots: d.lots),
),
const SizedBox(height: 16),
_Section(
title: 'События',
child: d.events.isEmpty
? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.')
? const EmptyState(
icon: Icons.receipt_long,
message: 'Событий нет.',
)
: _EventsTable(events: d.events),
),
],
@@ -97,10 +146,32 @@ class _Header extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(instrument.name, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(facts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
Row(
children: [
AssetIcon(
assetClass: instrument.assetClass,
logoUrl: instrument.logoUrl,
logoColor: instrument.logoColor,
size: 48,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(instrument.name, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
facts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
const SizedBox(height: 16),
if (holding == null)
Text('Позиция закрыта', style: theme.textTheme.bodyMedium)
@@ -139,7 +210,10 @@ class _HoldingFacts extends StatelessWidget {
children: [
h.marketPrice == null
? const Text('')
: MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency),
: MoneyText(
h.marketPrice!,
currency: h.priceCurrency ?? h.currency,
),
const SizedBox(width: 6),
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
],
@@ -147,7 +221,9 @@ class _HoldingFacts extends StatelessWidget {
),
_Fact(
label: 'Стоимость',
value: h.valueRub == null ? const Text('') : MoneyText(h.valueRub!, currency: 'RUB'),
value: h.valueRub == null
? const Text('')
: MoneyText(h.valueRub!, currency: 'RUB'),
),
_Fact(
label: 'Нереализованная',
@@ -156,14 +232,19 @@ class _HoldingFacts extends StatelessWidget {
: MoneyText(
h.unrealizedPnlRub!,
currency: 'RUB',
style: TextStyle(color: signColor(context, h.unrealizedPnlRub)),
style: TextStyle(
color: signColor(context, h.unrealizedPnlRub),
),
),
),
_Fact(
label: 'Реализованная',
value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'),
),
_Fact(label: 'Выплаты', value: MoneyText(h.incomeRub ?? '0', currency: 'RUB')),
_Fact(
label: 'Выплаты',
value: MoneyText(h.incomeRub ?? '0', currency: 'RUB'),
),
_Fact(
label: 'XIRR',
value: Text(
@@ -195,7 +276,10 @@ class _Fact extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
TermLabel(
label,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
const SizedBox(height: 2),
DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value),
],
@@ -230,8 +314,12 @@ class _PriceChart extends StatelessWidget {
interval: (prices.length / 4).clamp(1, double.infinity),
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= prices.length) return const SizedBox.shrink();
return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall);
if (i < 0 || i >= prices.length)
return const SizedBox.shrink();
return Text(
ruMonthYearShort(prices[i].d),
style: theme.textTheme.bodySmall,
);
},
),
),
@@ -245,7 +333,7 @@ class _PriceChart extends StatelessWidget {
isCurved: false,
color: ChartColors.slot1Blue,
barWidth: 2,
dotData: const FlDotData(show: false),
dotData: FlDotData(show: prices.length < 2),
),
],
),
@@ -268,9 +356,15 @@ class _LotsTable extends StatelessWidget {
columns: [
DataColumn(label: Text('Открыт', style: headerStyle)),
DataColumn(label: Text('Куплено', style: headerStyle), numeric: true),
DataColumn(label: Text('Осталось', style: headerStyle), numeric: true),
DataColumn(
label: Text('Осталось', style: headerStyle),
numeric: true,
),
DataColumn(label: Text('Цена', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true),
DataColumn(
label: Text('Стоимость, ₽', style: headerStyle),
numeric: true,
),
DataColumn(label: Text('Закрыт', style: headerStyle)),
],
rows: [
@@ -280,11 +374,17 @@ class _LotsTable extends StatelessWidget {
DataCell(Text(ruDate(lot.openDate))),
DataCell(Text(formatQty(lot.qtyOpen))),
DataCell(Text(formatQty(lot.qtyRemaining))),
DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)),
DataCell(lot.costTotalRub == null
? const Text('')
: MoneyText(lot.costTotalRub!, currency: 'RUB')),
DataCell(Text(lot.closedAt == null ? '' : ruDate(lot.closedAt!))),
DataCell(
MoneyText(lot.costPerUnit, currency: lot.costCurrency),
),
DataCell(
lot.costTotalRub == null
? const Text('')
: MoneyText(lot.costTotalRub!, currency: 'RUB'),
),
DataCell(
Text(lot.closedAt == null ? '' : ruDate(lot.closedAt!)),
),
],
),
],
@@ -317,32 +417,48 @@ class _EventsTable extends StatelessWidget {
DataRow(
cells: [
DataCell(Text(ruDate(e.tradeDate))),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(eventKindLabel(e.kind)),
if (e.externalFlow) ...[
const SizedBox(width: 4),
const Tooltip(
message: 'Внешний поток — учитывается в XIRR',
child: Icon(Icons.swap_vert, size: 14),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(eventKindLabel(e.kind)),
if (e.externalFlow) ...[
const SizedBox(width: 4),
const Tooltip(
message: 'Внешний поток — учитывается в XIRR',
child: Icon(Icons.swap_vert, size: 14),
),
],
],
],
)),
DataCell(Text(e.quantity == null ? '' : formatQty(e.quantity!))),
DataCell(e.price == null
? const Text('')
: MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)),
DataCell(MoneyText(
e.amount,
currency: e.currency,
style: TextStyle(color: signColor(context, e.amount)),
)),
DataCell(SizedBox(
width: 260,
child: Text(e.description ?? '', overflow: TextOverflow.ellipsis),
)),
),
),
DataCell(
Text(e.quantity == null ? '' : formatQty(e.quantity!)),
),
DataCell(
e.price == null
? const Text('')
: MoneyText(
e.price!,
currency: e.priceCurrency ?? e.currency,
),
),
DataCell(
MoneyText(
e.amount,
currency: e.currency,
style: TextStyle(color: signColor(context, e.amount)),
),
),
DataCell(
SizedBox(
width: 260,
child: Text(
e.description ?? '',
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],