Files
fin-tracker/app/lib/features/portfolio/benchmarks_card.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

198 lines
6.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<BenchmarksApi>(
(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<Cached<List<BenchmarkRow>>>((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),
],
),
);
}
}