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>( 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 = [ _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, ], ), ), ); } }