import 'package:decimal/decimal.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/theme/chart_colors.dart'; import '../../core/utils/compact_number.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({ 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) { final summary = ref.watch(portfolioSummaryProvider); 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: [ 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: 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), _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), ), ), ], ), ); } } class _SummaryTiles extends StatelessWidget { const _SummaryTiles({required this.summary}); final SummaryOut summary; @override Widget build(BuildContext context) { final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull; return Wrap( spacing: 12, runSpacing: 12, children: [ _Tile( label: 'Стоимость', value: MoneyText(summary.totalRub, currency: 'RUB'), note: 'в т.ч. кэш ${MoneyText.format(summary.cashRub, 'RUB')}', ), _Tile( label: 'Вложено', value: MoneyText(summary.investedNetRub, currency: 'RUB'), note: 'внешние потоки нетто', ), _Tile( label: 'Прибыль', value: summary.pnlTotalRub == null ? const Text('—') : MoneyText( summary.pnlTotalRub!, currency: 'RUB', style: TextStyle( color: signColor(context, summary.pnlTotalRub), ), ), note: summary.pnlTotalRub == null ? 'часть позиций без цены' : 'реализовано ${MoneyText.format(summary.realizedPnlRub, 'RUB')}', ), _Tile( label: 'Выплаты', value: MoneyText(summary.incomeRub, currency: 'RUB'), note: 'дивиденды и купоны', ), _Tile( label: 'XIRR, год', value: Text( formatPercent(yearly?.xirr), style: TextStyle(color: signColor(context, yearly?.xirr)), ), note: 'денежно-взвешенная', ), _Tile( label: 'TWR, год', value: Text( formatPercent(yearly?.twr), style: TextStyle(color: signColor(context, yearly?.twr)), ), note: (yearly?.twrDaysSkipped ?? 0) > 0 ? 'пропущено ${yearly!.twrDaysSkipped} дн.' : 'без учёта пополнений', ), if (summary.unpricedCount > 0 || summary.staleCount > 0) _Tile( label: 'Цены', value: Text('${summary.unpricedCount} / ${summary.staleCount}'), note: 'без цены / устаревших', ), ], ); } } class _Tile extends StatelessWidget { const _Tile({required this.label, required this.value, this.note}); final String label; final Widget value; final String? note; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SizedBox( width: 184, child: Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TermLabel(label, style: theme.textTheme.bodySmall), const SizedBox(height: 4), DefaultTextStyle( style: theme.textTheme.titleMedium!, child: value, ), if (note != null) ...[ const SizedBox(height: 2), Text( note!, style: theme.textTheme.bodySmall?.copyWith( color: theme.hintColor, ), maxLines: 2, ), ], ], ), ), ), ); } } class _ValueChart extends StatelessWidget { const _ValueChart({required this.rows}); final List rows; @override Widget build(BuildContext context) { final theme = Theme.of(context); final spots = []; final invested = []; for (var i = 0; i < rows.length; i++) { spots.add(FlSpot(i.toDouble(), _d(rows[i].totalRub))); invested.add(FlSpot(i.toDouble(), _d(rows[i].investedNetRub))); } const names = ['Стоимость', 'Вложено (нетто)']; final chart = SizedBox( height: 220, child: LineChart( LineChartData( gridData: const FlGridData(show: true, drawVerticalLine: false), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(), rightTitles: const AxisTitles(), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 56, getTitlesWidget: (value, meta) { // the axis ends sit on the data's min/max and land on top of the round // labels next to them (414,6 тыс over 400 тыс) if (value == meta.min || value == meta.max) { return const SizedBox.shrink(); } return SideTitleWidget( axisSide: meta.axisSide, child: Text( compactNumber(value), style: theme.textTheme.bodySmall, ), ); }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, // ticks at the quarters of the span, so the last one is the last day interval: ((rows.length - 1) / 4).clamp(1.0, double.infinity), getTitlesWidget: (value, meta) { // float steps can land a label a hair before the last one; keep only one if (value != meta.max && meta.max - value < meta.appliedInterval * 0.1) { return const SizedBox.shrink(); } final i = value.round(); if (i < 0 || i >= rows.length) return const SizedBox.shrink(); return SideTitleWidget( axisSide: meta.axisSide, fitInside: SideTitleFitInsideData.fromTitleMeta(meta), child: Text( ruMonthYearShort(rows[i].d), style: theme.textTheme.bodySmall, ), ); }, ), ), ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: false, color: ChartColors.slot1Blue, barWidth: 2, dotData: const FlDotData(show: false), ), LineChartBarData( spots: invested, isCurved: false, color: ChartColors.slot4Yellow, barWidth: 1.5, dashArray: const [4, 3], dotData: const FlDotData(show: false), ), ], lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (touched) => [ for (final (i, t) in touched.indexed) LineTooltipItem( '${i == 0 ? '${ruDate(rows[t.x.round()].d)}\n' : ''}' '${names[t.barIndex]}: ' '${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}', theme.textTheme.bodySmall ?? const TextStyle(), ), ], ), ), ), ), ); // the dashed line has no other explanation: say what each of the two is Widget key(Color color, String label, {bool dashed = false}) => Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 18, child: dashed ? Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ for (var i = 0; i < 3; i++) Container(width: 4, height: 2, color: color), ], ) : Container(height: 2, color: color), ), const SizedBox(width: 6), Text(label, style: theme.textTheme.bodySmall), ], ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ chart, const SizedBox(height: 8), Wrap( spacing: 16, runSpacing: 4, children: [ key(ChartColors.slot1Blue, names[0]), key(ChartColors.slot4Yellow, names[1], dashed: true), ], ), ], ); } } class _ReturnsTable extends StatelessWidget { const _ReturnsTable({required this.rows}); final List 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: 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.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), ), ], ], ), ), ], ), ], ), ); } } class _Card extends StatelessWidget { const _Card({required this.child, this.title}); final String? title; final Widget child; @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title != null) ...[ TermLabel(title!, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), ], child, ], ), ), ); } }