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