Files
fin-tracker/app/lib/features/portfolio/holdings_tab.dart
T
Dmitry 6891028074 fix(app): подписи осей и легенда графика стоимости
Крайние подписи оси Y (414.6K поверх 400K) скрыты, числа в русской краткой форме, подписи оси X не обрезаются справа. Добавлена легенда: сплошная линия — стоимость, пунктир — вложено нетто.
2026-09-20 10:46:35 +03:00

500 lines
16 KiB
Dart

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 '../../core/theme/chart_colors.dart';
import '../../core/utils/compact_number.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 '../../core/widgets/help_tip.dart';
import 'benchmarks_card.dart';
import 'holdings_table.dart';
import 'labels.dart';
import 'providers.dart';
double _d(String s) => Decimal.parse(s).toDouble();
/// Which parts of the screen a [HoldingsTab] draws. The same providers feed all of them, so
/// the numbers on Портфель and on Аналитика → Общее can never disagree.
enum HoldingsSections {
/// Everything, top to bottom.
all,
/// Only the assets table — the Портфель screen.
table,
/// The charts and returns, without the table — Аналитика → Общее.
overview,
}
/// Позиции: what the portfolio holds, what it is worth, and what it earned.
class HoldingsTab extends ConsumerWidget {
const HoldingsTab({
this.sections = HoldingsSections.all,
this.header,
super.key,
});
final HoldingsSections sections;
/// Drawn above everything else, inside the same scrolling list.
final Widget? header;
@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);
final all = sections == HoldingsSections.all;
final withTable = all || sections == HoldingsSections.table;
final withCharts = all || sections == HoldingsSections.overview;
return RefreshIndicator(
onRefresh: () async => invalidatePortfolioProviders(ref),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (header != null) ...[header!, const SizedBox(height: 16)],
if (all) ...[
AsyncValueView(
value: summary,
onRetry: () => ref.invalidate(portfolioSummaryProvider),
data: (cached) => _SummaryTiles(summary: cached.data),
),
const SizedBox(height: 20),
],
if (withCharts) ...[
_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(),
),
],
if (withTable && withCharts) const SizedBox(height: 16),
if (withTable)
_Card(
title: all ? 'Позиции' : null,
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: [
TermLabel(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<ValueDay> rows;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final spots = <FlSpot>[];
final invested = <FlSpot>[];
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)));
}
const names = ['Стоимость', 'Вложено (нетто)'];
final chart = 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: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 56,
getTitlesWidget: (value, meta) {
// the axis ends sit on the data's min/max and land on top of the round
// labels next to them (414,6 тыс over 400 тыс)
if (value == meta.min || value == meta.max) {
return const SizedBox.shrink();
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
compactNumber(value),
style: theme.textTheme.bodySmall,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
// ticks at the quarters of the span, so the last one is the last day
interval: ((rows.length - 1) / 4).clamp(1.0, double.infinity),
getTitlesWidget: (value, meta) {
// float steps can land a label a hair before the last one; keep only one
if (value != meta.max &&
meta.max - value < meta.appliedInterval * 0.1) {
return const SizedBox.shrink();
}
final i = value.round();
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return SideTitleWidget(
axisSide: meta.axisSide,
fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
child: 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 (i, t) in touched.indexed)
LineTooltipItem(
'${i == 0 ? '${ruDate(rows[t.x.round()].d)}\n' : ''}'
'${names[t.barIndex]}: '
'${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}',
theme.textTheme.bodySmall ?? const TextStyle(),
),
],
),
),
),
),
);
// the dashed line has no other explanation: say what each of the two is
Widget key(Color color, String label, {bool dashed = false}) => Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
child: dashed
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (var i = 0; i < 3; i++)
Container(width: 4, height: 2, color: color),
],
)
: Container(height: 2, color: color),
),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.bodySmall),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
chart,
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 4,
children: [
key(ChartColors.slot1Blue, names[0]),
key(ChartColors.slot4Yellow, names[1], dashed: true),
],
),
],
);
}
}
class _ReturnsTable extends StatelessWidget {
const _ReturnsTable({required this.rows});
final List<ReturnsOut> 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: TermLabel(
'Прибыль',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Потоки',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'XIRR',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'TWR',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
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 _Card extends StatelessWidget {
const _Card({required this.child, this.title});
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: [
if (title != null) ...[
TermLabel(title!, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
],
child,
],
),
),
);
}
}