import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; import '../../core/cache/cached.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import 'data/benchmarks_api.dart'; import 'labels.dart'; import 'providers.dart'; final benchmarksApiProvider = Provider( (ref) => BenchmarksApi(ref.watch(apiProvider).dio), ); /// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own: /// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See /// `docs/ai/offline-cache.md`. final benchmarkRowsProvider = FutureProvider.autoDispose>>((ref) async { final scope = ref.watch(scopeProvider); return ref.watch(benchmarksApiProvider).compare(scope: scope); }); /// The comparison block on Портфель. /// /// Two things are always marked: a **price** index (no dividends — it understates the /// holder's result by construction) and any non-zero `days_skipped` on either side (the two /// returns then do not cover the same days, so the difference is not strictly like-for-like). class BenchmarksCard extends ConsumerWidget { const BenchmarksCard({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final rows = ref.watch(benchmarkRowsProvider); return AsyncValueView( value: rows, onRetry: () => ref.invalidate(benchmarkRowsProvider), data: (cached) => cached.data.isEmpty ? const EmptyState( icon: Icons.compare_arrows, message: 'Бенчмарки не настроены — сравнивать не с чем.', ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final row in cached.data) Padding( padding: const EdgeInsets.only(bottom: 12), child: _PeriodBlock(row: row), ), ], ), ); } } class _PeriodBlock extends StatelessWidget { const _PeriodBlock({required this.row}); final BenchmarkRow row; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text(periodLabel(row.period), style: theme.textTheme.titleSmall), const SizedBox(width: 8), if (row.dateFrom != null && row.dateTo != null) Text( '${ruDate(row.dateFrom!)} – ${ruDate(row.dateTo!)}', style: theme.textTheme.bodySmall?.copyWith( color: theme.hintColor, ), ), const Spacer(), Text( 'портфель ${formatPercent(row.portfolioTwr)}', style: theme.textTheme.titleSmall?.copyWith( color: signColor(context, row.portfolioTwr), ), ), if (row.portfolioDaysSkipped > 0) ...[ const SizedBox(width: 6), _SkippedChip(days: row.portfolioDaysSkipped, side: 'портфеля'), ], ], ), const SizedBox(height: 6), for (final b in row.benchmarks) _BenchmarkRowView(result: b), if (row.benchmarksSkipDays) Padding( padding: const EdgeInsets.only(top: 6), child: Text( 'У индекса нет котировок в часть дней окна — сравнение не строго день в день.', style: theme.textTheme.bodySmall?.copyWith( color: theme.hintColor, ), ), ), const Divider(height: 20), ], ); } } class _BenchmarkRowView extends StatelessWidget { const _BenchmarkRowView({required this.result}); final BenchmarkResult result; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( children: [ Text(result.code, style: theme.textTheme.bodyMedium), const SizedBox(width: 6), if (result.isPriceIndex) const _PriceIndexChip(), if (result.daysSkipped > 0) ...[ const SizedBox(width: 6), _SkippedChip(days: result.daysSkipped, side: 'бенчмарка'), ], const Spacer(), Text(formatPercent(result.twr), style: theme.textTheme.bodyMedium), const SizedBox(width: 16), SizedBox( width: 92, child: Text( formatPercent(result.excess), textAlign: TextAlign.right, style: theme.textTheme.bodyMedium?.copyWith( color: signColor(context, result.excess), ), ), ), ], ), ); } } class _PriceIndexChip extends StatelessWidget { const _PriceIndexChip(); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Tooltip( message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает ' 'результат держателя. Сравнение с ним — нижняя граница, а не эталон.', child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: scheme.errorContainer, borderRadius: BorderRadius.circular(6), ), child: Text( 'ценовой индекс', style: Theme.of(context).textTheme.labelSmall, ), ), ); } } class _SkippedChip extends StatelessWidget { const _SkippedChip({required this.days, required this.side}); final int days; final String side; @override Widget build(BuildContext context) { return Tooltip( message: 'В расчёте $side не учтено $days дн.: в эти дни у части позиций не было цены', child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.info_outline, size: 14), const SizedBox(width: 2), Text('$days дн.', style: Theme.of(context).textTheme.labelSmall), ], ), ); } }