Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
223 lines
7.5 KiB
Dart
223 lines
7.5 KiB
Dart
import 'package:decimal/decimal.dart';
|
|
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/cache/cached.dart';
|
|
import '../../core/theme/chart_colors.dart';
|
|
import '../../core/widgets/async_value_view.dart';
|
|
import '../../core/widgets/money_text.dart';
|
|
import '../home/providers.dart' show scopeCardsProvider;
|
|
import '../../core/widgets/help_tip.dart';
|
|
import 'labels.dart';
|
|
import 'providers.dart';
|
|
|
|
/// The four figures on top of Аналитика → Общее: what the scope is worth, what it earned, its
|
|
/// return and the passive income it should bring in a year. Everything comes from the same
|
|
/// providers as Портфель and the home cards, so the numbers cannot disagree between screens.
|
|
class OverviewTiles extends ConsumerWidget {
|
|
const OverviewTiles({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final summary = ref.watch(portfolioSummaryProvider);
|
|
final scope = ref.watch(scopeProvider);
|
|
final card = ref
|
|
.watch(scopeCardsProvider)
|
|
.valueOrNull
|
|
?.data
|
|
.where((c) => c.scope == scope)
|
|
.firstOrNull;
|
|
|
|
return AsyncValueView<Cached<SummaryOut>>(
|
|
value: summary,
|
|
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
|
data: (cached) => _Grid(summary: cached.data, card: card),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Grid extends StatelessWidget {
|
|
const _Grid({required this.summary, required this.card});
|
|
|
|
final SummaryOut summary;
|
|
final ScopeCardOut? card;
|
|
|
|
static const _gap = 16.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final scheme = theme.colorScheme;
|
|
final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull;
|
|
final all = summary.returns.where((r) => r.period == 'all').firstOrNull;
|
|
final xirr = card?.xirr ?? all?.xirr;
|
|
final pnl = summary.pnlTotalRub;
|
|
final pnlShare =
|
|
pnl != null && Decimal.parse(summary.investedNetRub) > Decimal.zero
|
|
? (Decimal.parse(pnl) / Decimal.parse(summary.investedNetRub))
|
|
.toDecimal(scaleOnInfinitePrecision: 10)
|
|
.toString()
|
|
: null;
|
|
final day = card?.dayChangeRub;
|
|
final income = card?.incomeYearRub;
|
|
|
|
Widget muted(String text, {Color? color}) => Text(
|
|
text,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: color ?? scheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
|
|
final tiles = <Widget>[
|
|
_Tile(
|
|
icon: Icons.account_balance_wallet_outlined,
|
|
label: 'Стоимость',
|
|
value: Text(MoneyText.format(summary.totalRub, 'RUB')),
|
|
note: muted(
|
|
'${MoneyText.format(summary.investedNetRub, 'RUB')} вложено',
|
|
),
|
|
),
|
|
_Tile(
|
|
icon: Icons.show_chart,
|
|
label: 'Прибыль',
|
|
value: pnl == null
|
|
? const Text('—')
|
|
: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Text(
|
|
(Decimal.parse(pnl) > Decimal.zero ? '+' : '') +
|
|
MoneyText.format(pnl, 'RUB'),
|
|
style: TextStyle(color: signColor(context, pnl)),
|
|
),
|
|
if (pnlShare != null)
|
|
Text(
|
|
' ${formatPercent(pnlShare)}',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: signColor(context, pnl),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
note: day == null
|
|
? muted(pnl == null ? 'часть позиций без цены' : 'за день —')
|
|
: muted(
|
|
'${Decimal.parse(day) > Decimal.zero ? '+' : ''}${MoneyText.format(day, 'RUB')}'
|
|
'${card?.dayChangePct == null ? '' : ' ${formatPercent(card!.dayChangePct)}'} за день',
|
|
color: signColor(context, day),
|
|
),
|
|
),
|
|
_Tile(
|
|
icon: Icons.percent,
|
|
label: 'Доходность',
|
|
value: Text(
|
|
formatPercent(xirr, signed: false),
|
|
style: TextStyle(color: signColor(context, xirr)),
|
|
),
|
|
note: muted(
|
|
yearly?.twr == null
|
|
? 'денежно-взвешенная, с начала'
|
|
: 'рост активов ${formatPercent(yearly!.twr)} за год',
|
|
),
|
|
),
|
|
_Tile(
|
|
icon: Icons.savings_outlined,
|
|
label: 'Пассивный доход',
|
|
value: Text(
|
|
card?.incomeYearPct == null
|
|
? '—'
|
|
: formatPercent(card!.incomeYearPct, signed: false),
|
|
),
|
|
note: muted(
|
|
income == null || Decimal.parse(income) == Decimal.zero
|
|
? 'прогноза выплат пока нет'
|
|
: '${MoneyText.format(income, 'RUB')} в год',
|
|
color: income == null || Decimal.parse(income) == Decimal.zero
|
|
? null
|
|
: ChartColors.gain,
|
|
),
|
|
),
|
|
];
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final columns = width >= 1000 ? 4 : (width >= 560 ? 2 : 1);
|
|
final itemWidth = (width - _gap * (columns - 1)) / columns;
|
|
return Wrap(
|
|
spacing: _gap,
|
|
runSpacing: _gap,
|
|
children: [
|
|
for (final t in tiles) SizedBox(width: itemWidth, child: t),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Tile extends StatelessWidget {
|
|
const _Tile({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.note,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final Widget value;
|
|
final Widget note;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final scheme = theme.colorScheme;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: scheme.primary.withValues(alpha: 0.16),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, size: 18, color: scheme.primary),
|
|
),
|
|
const SizedBox(width: 10),
|
|
TermLabel(
|
|
label,
|
|
hint: label == 'Стоимость'
|
|
? 'Стоимость — всё сразу: бумаги по последним ценам плюс деньги на счёте. '
|
|
'Ниже — сколько вы вложили (пополнения минус выводы).'
|
|
: null,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: scheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
DefaultTextStyle.merge(
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
child: value,
|
|
),
|
|
const SizedBox(height: 8),
|
|
note,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|