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 '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 'benchmarks_card.dart'; import 'labels.dart'; import 'providers.dart'; double _d(String s) => Decimal.parse(s).toDouble(); /// Позиции: what the portfolio holds, what it is worth, and what it earned. class HoldingsTab extends ConsumerWidget { const HoldingsTab({super.key}); @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); 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), ), ), 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(), ), 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), ), ), ], ), ); } } 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: [ Text(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))); } return 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: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 56), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, interval: (rows.length / 4).clamp(1, double.infinity), 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); }, ), ), ), 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 t in touched) LineTooltipItem( '${ruDate(rows[t.x.round()].d)}\n' '${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}', theme.textTheme.bodySmall ?? const TextStyle(), ), ], ), ), ), ), ); } } 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: 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), ], 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 _HoldingsTable extends StatelessWidget { const _HoldingsTable({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: 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)), )), ], ), ], ), ); } } class _Card extends StatelessWidget { const _Card({required this.title, required this.child}); 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: [ Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), child, ], ), ), ); } }