feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -3,22 +3,45 @@ 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 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'benchmarks_card.dart';
|
||||
import 'holdings_table.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
/// Which parts of the screen a [HoldingsTab] draws. The same providers feed all of them, so
|
||||
/// the numbers on Портфель and on Аналитика → Общее can never disagree.
|
||||
enum HoldingsSections {
|
||||
/// Everything, top to bottom.
|
||||
all,
|
||||
|
||||
/// Only the assets table — the Портфель screen.
|
||||
table,
|
||||
|
||||
/// The charts and returns, without the table — Аналитика → Общее.
|
||||
overview,
|
||||
}
|
||||
|
||||
/// Позиции: what the portfolio holds, what it is worth, and what it earned.
|
||||
class HoldingsTab extends ConsumerWidget {
|
||||
const HoldingsTab({super.key});
|
||||
const HoldingsTab({
|
||||
this.sections = HoldingsSections.all,
|
||||
this.header,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final HoldingsSections sections;
|
||||
|
||||
/// Drawn above everything else, inside the same scrolling list.
|
||||
final Widget? header;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -26,58 +49,73 @@ class HoldingsTab extends ConsumerWidget {
|
||||
final holdings = ref.watch(holdingsProvider);
|
||||
final series = ref.watch(valueSeriesProvider);
|
||||
final returns = ref.watch(portfolioReturnsProvider);
|
||||
final all = sections == HoldingsSections.all;
|
||||
final withTable = all || sections == HoldingsSections.table;
|
||||
final withCharts = all || sections == HoldingsSections.overview;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => invalidatePortfolioProviders(ref),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AsyncValueView(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _SummaryTiles(summary: cached.data),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_Card(
|
||||
title: 'Стоимость за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(valueSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
: _ValueChart(rows: cached.data),
|
||||
if (header != null) ...[header!, const SizedBox(height: 16)],
|
||||
if (all) ...[
|
||||
AsyncValueView(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _SummaryTiles(summary: cached.data),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Доходность',
|
||||
child: AsyncValueView(
|
||||
value: returns,
|
||||
onRetry: () => ref.invalidate(portfolioReturnsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.')
|
||||
: _ReturnsTable(rows: cached.data),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
if (withCharts) ...[
|
||||
_Card(
|
||||
title: 'Стоимость за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(valueSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.show_chart,
|
||||
message: 'Пока нет данных.',
|
||||
)
|
||||
: _ValueChart(rows: cached.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Позиции',
|
||||
child: AsyncValueView(
|
||||
value: holdings,
|
||||
onRetry: () => ref.invalidate(holdingsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых позиций нет — нужна синхронизация брокера.',
|
||||
)
|
||||
: _HoldingsTable(rows: cached.data),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Доходность',
|
||||
child: AsyncValueView(
|
||||
value: returns,
|
||||
onRetry: () => ref.invalidate(portfolioReturnsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.percent,
|
||||
message: 'Пока нечего считать.',
|
||||
)
|
||||
: _ReturnsTable(rows: cached.data),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
],
|
||||
if (withTable && withCharts) const SizedBox(height: 16),
|
||||
if (withTable)
|
||||
_Card(
|
||||
title: all ? 'Позиции' : null,
|
||||
child: AsyncValueView(
|
||||
value: holdings,
|
||||
onRetry: () => ref.invalidate(holdingsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых позиций нет — нужна синхронизация брокера.',
|
||||
)
|
||||
: HoldingsTable(rows: cached.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -113,7 +151,9 @@ class _SummaryTiles extends StatelessWidget {
|
||||
: MoneyText(
|
||||
summary.pnlTotalRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, summary.pnlTotalRub)),
|
||||
style: TextStyle(
|
||||
color: signColor(context, summary.pnlTotalRub),
|
||||
),
|
||||
),
|
||||
note: summary.pnlTotalRub == null
|
||||
? 'часть позиций без цены'
|
||||
@@ -171,14 +211,19 @@ class _Tile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
TermLabel(label, style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value),
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.titleMedium!,
|
||||
child: value,
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
note!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
@@ -224,7 +269,10 @@ class _ValueChart extends StatelessWidget {
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Text(ruMonthYearShort(rows[i].d), style: theme.textTheme.bodySmall);
|
||||
return Text(
|
||||
ruMonthYearShort(rows[i].d),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -277,112 +325,78 @@ class _ReturnsTable extends StatelessWidget {
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Период', style: headerStyle)),
|
||||
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Потоки', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('XIRR', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('TWR', style: headerStyle), numeric: true),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'Прибыль',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'Потоки',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'XIRR',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'TWR',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(periodLabel(r.period))),
|
||||
DataCell(MoneyText(
|
||||
r.absPnlRub,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.absPnlRub)),
|
||||
)),
|
||||
DataCell(
|
||||
MoneyText(
|
||||
r.absPnlRub,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.absPnlRub)),
|
||||
),
|
||||
),
|
||||
DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')),
|
||||
DataCell(Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatPercent(r.twr),
|
||||
style: TextStyle(color: signColor(context, r.twr)),
|
||||
),
|
||||
if (r.twrDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: 'Пропущено ${r.twrDaysSkipped} дн.: '
|
||||
'в эти дни часть позиции была без цены',
|
||||
child: const Icon(Icons.info_outline, size: 14),
|
||||
DataCell(
|
||||
Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatPercent(r.twr),
|
||||
style: TextStyle(color: signColor(context, r.twr)),
|
||||
),
|
||||
if (r.twrDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message:
|
||||
'Пропущено ${r.twrDaysSkipped} дн.: '
|
||||
'в эти дни часть позиции была без цены',
|
||||
child: const Icon(Icons.info_outline, size: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoldingsTable extends StatelessWidget {
|
||||
const _HoldingsTable({required this.rows});
|
||||
|
||||
final List<HoldingOut> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerStyle = Theme.of(context).textTheme.labelMedium;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Бумага', style: headerStyle)),
|
||||
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('XIRR', style: headerStyle), numeric: true),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
onSelectChanged: (_) => context.push('/portfolio/instrument/${r.instrumentId}'),
|
||||
cells: [
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
r.ticker ?? r.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
PriceStatusChip(status: r.priceStatus, priceDate: r.priceDate),
|
||||
],
|
||||
)),
|
||||
DataCell(Text(assetClassLabel(r.assetClass))),
|
||||
DataCell(Text(formatQty(r.qty))),
|
||||
DataCell(r.marketPrice == null
|
||||
? const Text('—')
|
||||
: MoneyText(r.marketPrice!, currency: r.priceCurrency ?? r.currency)),
|
||||
// a position with no price shows nothing, never 0 ₽: it is absent from the
|
||||
// totals above, and a zero would read as "worthless" instead of "unknown"
|
||||
DataCell(r.valueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(r.valueRub!, currency: 'RUB')),
|
||||
DataCell(Text(formatPercent(r.weight, signed: false))),
|
||||
DataCell(r.unrealizedPnlRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(
|
||||
r.unrealizedPnlRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.unrealizedPnlRub)),
|
||||
)),
|
||||
DataCell(Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -392,9 +406,9 @@ class _HoldingsTable extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.title, required this.child});
|
||||
const _Card({required this.child, this.title});
|
||||
|
||||
final String title;
|
||||
final String? title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -405,8 +419,10 @@ class _Card extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
if (title != null) ...[
|
||||
TermLabel(title!, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user