Files
fin-tracker/app/lib/features/portfolio/benchmarks_card.dart
T
Dmitry b69bb4a0c9 feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
2026-09-19 10:44:38 +03:00

184 lines
6.3 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/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.
final benchmarkRowsProvider = FutureProvider.autoDispose<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: (data) => data.isEmpty
? const EmptyState(
icon: Icons.compare_arrows,
message: 'Бенчмарки не настроены — сравнивать не с чем.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final row in 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.hasSkippedDays)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'Сетка дат не полностью совпадает: часть дней пропущена, '
'сравнение не строго like-for-like.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
),
),
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),
],
),
);
}
}