Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
198 lines
6.6 KiB
Dart
198 lines
6.6 KiB
Dart
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|