From 1d7769ffbf1a8dcb281e0286fc66cb8439dc256d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 18 Sep 2026 14:21:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=D1=80=D1=82=D1=84=D0=B5=D0=BB=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8,=20?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D0=BE=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации: они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх экранов — три экрана с разными scope были бы ловушкой. Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для общей прибыли, когда в портфеле есть хоть одна неоценённая бумага. Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя (assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает никогда. Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует. --- app/lib/features/home/home_page.dart | 56 +++ app/lib/features/home/providers.dart | 13 + .../features/portfolio/allocation_tab.dart | 215 +++++++++ app/lib/features/portfolio/holdings_tab.dart | 410 ++++++++++++++++++ .../features/portfolio/instrument_page.dart | 371 ++++++++++++++++ app/lib/features/portfolio/labels.dart | 142 ++++++ .../features/portfolio/portfolio_page.dart | 70 +++ app/lib/features/portfolio/providers.dart | 70 +++ app/lib/features/shell/app_shell.dart | 11 +- app/lib/router.dart | 8 + app/test/portfolio_test.dart | 166 +++++++ 11 files changed, 1530 insertions(+), 2 deletions(-) create mode 100644 app/lib/features/portfolio/allocation_tab.dart create mode 100644 app/lib/features/portfolio/holdings_tab.dart create mode 100644 app/lib/features/portfolio/instrument_page.dart create mode 100644 app/lib/features/portfolio/labels.dart create mode 100644 app/lib/features/portfolio/portfolio_page.dart create mode 100644 app/lib/features/portfolio/providers.dart create mode 100644 app/test/portfolio_test.dart diff --git a/app/lib/features/home/home_page.dart b/app/lib/features/home/home_page.dart index 5b0d119..887d3a7 100644 --- a/app/lib/features/home/home_page.dart +++ b/app/lib/features/home/home_page.dart @@ -107,6 +107,7 @@ class _HomePageState extends ConsumerState { final runway = ref.watch(runwayProvider); final status = ref.watch(metricsStatusProvider); final dataQuality = ref.watch(dataQualityProvider); + final portfolio = ref.watch(portfolioSummaryHomeProvider); return Scaffold( appBar: AppBar( @@ -175,6 +176,12 @@ class _HomePageState extends ConsumerState { ], ), const SizedBox(height: 12), + AsyncValueView( + value: portfolio, + onRetry: () => ref.invalidate(portfolioSummaryHomeProvider), + data: (s) => s == null ? const SizedBox.shrink() : _PortfolioTiles(summary: s), + ), + const SizedBox(height: 12), AsyncValueView( value: runway, onRetry: () => ref.invalidate(runwayProvider), @@ -476,3 +483,52 @@ class _LegendDot extends StatelessWidget { ); } } + +/// The investment row of the dashboard: value, profit and the two returns, each linking +/// through to Портфель. Absent entirely until a broker ledger exists. +class _PortfolioTiles extends StatelessWidget { + const _PortfolioTiles({required this.summary}); + + final SummaryOut summary; + + @override + Widget build(BuildContext context) { + final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull; + return InkWell( + onTap: () => context.go('/portfolio'), + borderRadius: BorderRadius.circular(12), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _StatTile( + label: 'Портфель', + value: MoneyText(summary.totalRub, currency: 'RUB'), + ), + _StatTile( + label: 'Прибыль', + value: summary.pnlTotalRub == null + // null, not zero: something in the portfolio has no price today + ? const Text('—') + : MoneyText(summary.pnlTotalRub!, currency: 'RUB'), + ), + _StatTile( + label: 'XIRR, год', + value: Text(_percent(yearly?.xirr)), + ), + _StatTile( + label: 'TWR, год', + value: Text(_percent(yearly?.twr)), + ), + ], + ), + ); + } + + static String _percent(String? value) { + if (value == null) return '—'; + final pct = _d(value) * 100; + final sign = pct > 0 ? '+' : ''; + return '$sign${pct.toStringAsFixed(2).replaceAll('.', ',')} %'; + } +} diff --git a/app/lib/features/home/providers.dart b/app/lib/features/home/providers.dart index 7ea58ed..7810fd6 100644 --- a/app/lib/features/home/providers.dart +++ b/app/lib/features/home/providers.dart @@ -63,4 +63,17 @@ void invalidateHomeProviders(WidgetRef ref) { ref.invalidate(runwayProvider); ref.invalidate(metricsStatusProvider); ref.invalidate(dataQualityProvider); + ref.invalidate(portfolioSummaryHomeProvider); } + +/// The investment side of the dashboard: one scope-wide summary, `all` by default. +/// Null when the ledger is empty, which is the normal state before a broker sync. +final portfolioSummaryHomeProvider = FutureProvider.autoDispose((ref) async { + try { + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(); + return r.data; + } on DioException catch (e) { + if (e.response?.statusCode == 404) return null; + rethrow; + } +}); diff --git a/app/lib/features/portfolio/allocation_tab.dart b/app/lib/features/portfolio/allocation_tab.dart new file mode 100644 index 0000000..86f53a2 --- /dev/null +++ b/app/lib/features/portfolio/allocation_tab.dart @@ -0,0 +1,215 @@ +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/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import 'labels.dart'; +import 'providers.dart'; + +double _d(String s) => Decimal.parse(s).toDouble(); + +/// Fixed categorical order — slot colours assigned by position in the sorted buckets, never +/// by rank across dimensions, so a bucket keeps its colour as the portfolio moves. +const _palette = [ + ChartColors.slot1Blue, + ChartColors.slot2Orange, + ChartColors.slot3Aqua, + ChartColors.slot4Yellow, + ChartColors.slot5Magenta, +]; + +/// Аллокация: one donut per dimension, each over the same total (securities plus cash). +class AllocationTab extends ConsumerWidget { + const AllocationTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final allocation = ref.watch(allocationProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(allocationProvider), + child: AsyncValueView( + value: allocation, + onRetry: () => ref.invalidate(allocationProvider), + data: (rows) { + if (rows.isEmpty) { + return ListView( + children: const [ + EmptyState( + icon: Icons.donut_large_outlined, + message: 'Аллокации ещё нет — нужен пересчёт метрик.', + ), + ], + ); + } + final byDimension = >{}; + for (final row in rows) { + byDimension.putIfAbsent(row.dimension, () => []).add(row); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final entry in byDimension.entries) ...[ + _DimensionCard(dimension: entry.key, buckets: entry.value), + const SizedBox(height: 16), + ], + ], + ); + }, + ), + ); + } +} + +class _DimensionCard extends StatelessWidget { + const _DimensionCard({required this.dimension, required this.buckets}); + + final AllocationDimension dimension; + final List buckets; + + @override + Widget build(BuildContext context) { + final onlyUnknown = buckets.every((b) => b.bucket == 'unknown' || b.bucket == 'cash'); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(dimensionLabel(dimension), style: Theme.of(context).textTheme.titleMedium), + if (onlyUnknown) ...[ + const SizedBox(height: 4), + Text( + 'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final donut = SizedBox( + height: 200, + child: _Donut(dimension: dimension, buckets: buckets), + ); + final legend = _Legend(dimension: dimension, buckets: buckets); + if (constraints.maxWidth >= 640) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(width: 220, child: donut), + const SizedBox(width: 24), + Expanded(child: legend), + ], + ); + } + return Column(children: [donut, const SizedBox(height: 12), legend]); + }, + ), + ], + ), + ), + ); + } +} + +class _Donut extends StatelessWidget { + const _Donut({required this.dimension, required this.buckets}); + + final AllocationDimension dimension; + final List buckets; + + @override + Widget build(BuildContext context) { + // a negative bucket (a short, an overdrawn balance) has no slice: a pie cannot draw one, + // and the legend still lists it with its real value + final positive = buckets.where((b) => _d(b.valueRub) > 0).toList(); + if (positive.isEmpty) { + return const EmptyState(icon: Icons.donut_large_outlined, message: 'Нечего показать.'); + } + return PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 48, + sections: [ + for (var i = 0; i < positive.length; i++) + PieChartSectionData( + value: _d(positive[i].valueRub), + color: _palette[i % _palette.length], + title: _d(positive[i].weight) >= 0.06 + ? formatPercent(positive[i].weight, signed: false) + : '', + titleStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + radius: 52, + ), + ], + ), + ); + } +} + +class _Legend extends StatelessWidget { + const _Legend({required this.dimension, required this.buckets}); + + final AllocationDimension dimension; + final List buckets; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + for (var i = 0; i < buckets.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _palette[i % _palette.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + bucketLabel(dimension, buckets[i].bucket), + overflow: TextOverflow.ellipsis, + ), + ), + if (buckets[i].holdingCount > 0) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text('${buckets[i].holdingCount}', + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + ), + MoneyText(buckets[i].valueRub, currency: 'RUB', style: theme.textTheme.bodyMedium), + const SizedBox(width: 12), + SizedBox( + width: 56, + child: Text( + formatPercent(buckets[i].weight, signed: false), + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/portfolio/holdings_tab.dart b/app/lib/features/portfolio/holdings_tab.dart new file mode 100644 index 0000000..171c4fc --- /dev/null +++ b/app/lib/features/portfolio/holdings_tab.dart @@ -0,0 +1,410 @@ +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 'package:go_router/go_router.dart'; + +import '../../core/theme/chart_colors.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 'labels.dart'; +import 'providers.dart'; + +double _d(String s) => Decimal.parse(s).toDouble(); + +/// Позиции: what the portfolio holds, what it is worth, and what it earned. +class HoldingsTab extends ConsumerWidget { + const HoldingsTab({super.key}); + + @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); + + return RefreshIndicator( + onRefresh: () async => invalidatePortfolioProviders(ref), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + AsyncValueView( + value: summary, + onRetry: () => ref.invalidate(portfolioSummaryProvider), + data: (s) => _SummaryTiles(summary: s), + ), + const SizedBox(height: 20), + _Card( + title: 'Стоимость за 365 дней', + child: AsyncValueView( + value: series, + onRetry: () => ref.invalidate(valueSeriesProvider), + data: (rows) => rows.isEmpty + ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') + : _ValueChart(rows: rows), + ), + ), + const SizedBox(height: 16), + _Card( + title: 'Доходность', + child: AsyncValueView( + value: returns, + onRetry: () => ref.invalidate(portfolioReturnsProvider), + data: (rows) => rows.isEmpty + ? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.') + : _ReturnsTable(rows: rows), + ), + ), + const SizedBox(height: 16), + _Card( + title: 'Позиции', + child: AsyncValueView( + value: holdings, + onRetry: () => ref.invalidate(holdingsProvider), + data: (rows) => rows.isEmpty + ? const EmptyState( + icon: Icons.inventory_2_outlined, + message: 'Открытых позиций нет — нужна синхронизация брокера.', + ) + : _HoldingsTable(rows: rows), + ), + ), + ], + ), + ); + } +} + +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: [ + Text(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 rows; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final spots = []; + final invested = []; + 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))); + } + return 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: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 56), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: (rows.length / 4).clamp(1, double.infinity), + getTitlesWidget: (value, meta) { + final i = value.round(); + if (i < 0 || i >= rows.length) return const SizedBox.shrink(); + return 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 t in touched) + LineTooltipItem( + '${ruDate(rows[t.x.round()].d)}\n' + '${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}', + theme.textTheme.bodySmall ?? const TextStyle(), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ReturnsTable extends StatelessWidget { + const _ReturnsTable({required this.rows}); + + final List 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: Text('Прибыль', style: headerStyle), numeric: true), + DataColumn(label: Text('Потоки', style: headerStyle), numeric: true), + DataColumn(label: Text('XIRR', style: headerStyle), numeric: true), + DataColumn(label: Text('TWR', style: headerStyle), 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 _HoldingsTable extends StatelessWidget { + const _HoldingsTable({required this.rows}); + + final List 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: Text('Класс', style: headerStyle)), + DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true), + DataColumn(label: Text('Цена', style: headerStyle), numeric: true), + DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true), + DataColumn(label: Text('Доля', style: headerStyle), numeric: true), + DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true), + DataColumn(label: Text('XIRR', style: headerStyle), numeric: true), + ], + rows: [ + for (final r in rows) + DataRow( + onSelectChanged: (_) => context.push('/portfolio/instrument/${r.instrumentId}'), + cells: [ + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + r.ticker ?? r.name, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + PriceStatusChip(status: r.priceStatus, priceDate: r.priceDate), + ], + )), + DataCell(Text(assetClassLabel(r.assetClass))), + DataCell(Text(formatQty(r.qty))), + DataCell(r.marketPrice == null + ? const Text('—') + : MoneyText(r.marketPrice!, currency: r.priceCurrency ?? r.currency)), + // a position with no price shows nothing, never 0 ₽: it is absent from the + // totals above, and a zero would read as "worthless" instead of "unknown" + DataCell(r.valueRub == null + ? const Text('—') + : MoneyText(r.valueRub!, currency: 'RUB')), + DataCell(Text(formatPercent(r.weight, signed: false))), + DataCell(r.unrealizedPnlRub == null + ? const Text('—') + : MoneyText( + r.unrealizedPnlRub!, + currency: 'RUB', + style: TextStyle(color: signColor(context, r.unrealizedPnlRub)), + )), + DataCell(Text( + formatPercent(r.xirr), + style: TextStyle(color: signColor(context, r.xirr)), + )), + ], + ), + ], + ), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({required this.title, required this.child}); + + 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: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} diff --git a/app/lib/features/portfolio/instrument_page.dart b/app/lib/features/portfolio/instrument_page.dart new file mode 100644 index 0000000..5f1ce1f --- /dev/null +++ b/app/lib/features/portfolio/instrument_page.dart @@ -0,0 +1,371 @@ +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/ru_date.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import 'labels.dart'; +import 'providers.dart'; + +double _d(String s) => Decimal.parse(s).toDouble(); + +/// The instrument card: the position, the lots behind its cost, every event that touched it +/// and the price history behind its value — the screen that answers "where does this number +/// come from" when a reconciliation finding points here. +class InstrumentPage extends ConsumerWidget { + const InstrumentPage({required this.instrumentId, super.key}); + + final int instrumentId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detail = ref.watch(instrumentProvider(instrumentId)); + + return Scaffold( + appBar: AppBar( + title: Text(detail.valueOrNull?.instrument.ticker ?? + detail.valueOrNull?.instrument.name ?? + 'Инструмент'), + ), + body: AsyncValueView( + value: detail, + onRetry: () => ref.invalidate(instrumentProvider(instrumentId)), + data: (d) => ListView( + padding: const EdgeInsets.all(16), + children: [ + _Header(instrument: d.instrument, holding: d.holding), + const SizedBox(height: 16), + _Section( + title: 'Цена', + child: d.prices.isEmpty + ? const EmptyState( + icon: Icons.show_chart, + message: 'Цен нет — стоимость позиции неизвестна.', + ) + : _PriceChart(prices: d.prices), + ), + const SizedBox(height: 16), + _Section( + title: 'Лоты', + child: d.lots.isEmpty + ? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.') + : _LotsTable(lots: d.lots), + ), + const SizedBox(height: 16), + _Section( + title: 'События', + child: d.events.isEmpty + ? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.') + : _EventsTable(events: d.events), + ), + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.instrument, required this.holding}); + + final InstrumentOut instrument; + final HoldingOut? holding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final facts = [ + assetClassLabel(instrument.assetClass), + if (instrument.board != null) instrument.board!, + if (instrument.isin != null) instrument.isin!, + if (instrument.country != null) instrument.country!, + instrument.currency, + ]; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(instrument.name, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text(facts.join(' · '), + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + const SizedBox(height: 16), + if (holding == null) + Text('Позиция закрыта', style: theme.textTheme.bodyMedium) + else + _HoldingFacts(holding: holding!), + ], + ), + ), + ); + } +} + +class _HoldingFacts extends StatelessWidget { + const _HoldingFacts({required this.holding}); + + final HoldingOut holding; + + @override + Widget build(BuildContext context) { + final h = holding; + return Wrap( + spacing: 24, + runSpacing: 12, + children: [ + _Fact(label: 'Количество', value: Text(formatQty(h.qty))), + _Fact( + label: 'Средняя цена', + value: h.avgCost == null + ? const Text('—') + : MoneyText(h.avgCost!, currency: h.costCurrency ?? h.currency), + ), + _Fact( + label: 'Текущая цена', + value: Row( + mainAxisSize: MainAxisSize.min, + children: [ + h.marketPrice == null + ? const Text('—') + : MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency), + const SizedBox(width: 6), + PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate), + ], + ), + ), + _Fact( + label: 'Стоимость', + value: h.valueRub == null ? const Text('—') : MoneyText(h.valueRub!, currency: 'RUB'), + ), + _Fact( + label: 'Нереализованная', + value: h.unrealizedPnlRub == null + ? const Text('—') + : MoneyText( + h.unrealizedPnlRub!, + currency: 'RUB', + style: TextStyle(color: signColor(context, h.unrealizedPnlRub)), + ), + ), + _Fact( + label: 'Реализованная', + value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'), + ), + _Fact(label: 'Выплаты', value: MoneyText(h.incomeRub ?? '0', currency: 'RUB')), + _Fact( + label: 'XIRR', + value: Text( + formatPercent(h.xirr), + style: TextStyle(color: signColor(context, h.xirr)), + ), + ), + if (h.firstBuyDate != null) + _Fact( + label: 'В портфеле', + value: Text('${h.daysHeld ?? 0} дн. с ${ruDate(h.firstBuyDate!)}'), + ), + if (_d(h.ldvEligibleQty) > 0) + _Fact(label: 'Под ЛДВ', value: Text(formatQty(h.ldvEligibleQty))), + ], + ); + } +} + +class _Fact extends StatelessWidget { + const _Fact({required this.label, required this.value}); + + final String label; + final Widget value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + const SizedBox(height: 2), + DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value), + ], + ); + } +} + +class _PriceChart extends StatelessWidget { + const _PriceChart({required this.prices}); + + final List prices; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + height: 200, + child: LineChart( + LineChartData( + gridData: const FlGridData(show: true, drawVerticalLine: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 52), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: (prices.length / 4).clamp(1, double.infinity), + getTitlesWidget: (value, meta) { + final i = value.round(); + if (i < 0 || i >= prices.length) return const SizedBox.shrink(); + return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: [ + for (var i = 0; i < prices.length; i++) + FlSpot(i.toDouble(), _d(prices[i].close)), + ], + isCurved: false, + color: ChartColors.slot1Blue, + barWidth: 2, + dotData: const FlDotData(show: false), + ), + ], + ), + ), + ); + } +} + +class _LotsTable extends StatelessWidget { + const _LotsTable({required this.lots}); + + final List lots; + + @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: Text('Куплено', style: headerStyle), numeric: true), + DataColumn(label: Text('Осталось', style: headerStyle), numeric: true), + DataColumn(label: Text('Цена', style: headerStyle), numeric: true), + DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true), + DataColumn(label: Text('Закрыт', style: headerStyle)), + ], + rows: [ + for (final lot in lots) + DataRow( + cells: [ + DataCell(Text(ruDate(lot.openDate))), + DataCell(Text(formatQty(lot.qtyOpen))), + DataCell(Text(formatQty(lot.qtyRemaining))), + DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)), + DataCell(lot.costTotalRub == null + ? const Text('—') + : MoneyText(lot.costTotalRub!, currency: 'RUB')), + DataCell(Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!))), + ], + ), + ], + ), + ); + } +} + +class _EventsTable extends StatelessWidget { + const _EventsTable({required this.events}); + + final List events; + + @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: Text('Тип', style: headerStyle)), + DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true), + DataColumn(label: Text('Цена', style: headerStyle), numeric: true), + DataColumn(label: Text('Сумма', style: headerStyle), numeric: true), + DataColumn(label: Text('Описание', style: headerStyle)), + ], + rows: [ + for (final e in events) + DataRow( + cells: [ + DataCell(Text(ruDate(e.tradeDate))), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(eventKindLabel(e.kind)), + if (e.externalFlow) ...[ + const SizedBox(width: 4), + const Tooltip( + message: 'Внешний поток — учитывается в XIRR', + child: Icon(Icons.swap_vert, size: 14), + ), + ], + ], + )), + DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))), + DataCell(e.price == null + ? const Text('—') + : MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)), + DataCell(MoneyText( + e.amount, + currency: e.currency, + style: TextStyle(color: signColor(context, e.amount)), + )), + DataCell(SizedBox( + width: 260, + child: Text(e.description ?? '', overflow: TextOverflow.ellipsis), + )), + ], + ), + ], + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({required this.title, required this.child}); + + 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: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} diff --git a/app/lib/features/portfolio/labels.dart b/app/lib/features/portfolio/labels.dart new file mode 100644 index 0000000..87421ec --- /dev/null +++ b/app/lib/features/portfolio/labels.dart @@ -0,0 +1,142 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; + +import '../../core/theme/chart_colors.dart'; +import '../../core/utils/ru_date.dart'; + +/// Russian labels for the keys the API sends as stable identifiers. +/// +/// The backend deliberately stores keys, not labels — `bucket` is `bond`, `cash`, +/// `unknown`, a country code or a currency — so the language lives here, on the one side +/// that has a language at all. +const assetClassLabels = { + 'share': 'Акции', + 'bond': 'Облигации', + 'etf': 'Фонды', + 'fund': 'ПИФы', + 'currency': 'Валюта', + 'index': 'Индексы', + 'deposit': 'Депозиты', + 'real_estate': 'Недвижимость', + 'crypto': 'Криптовалюта', + 'custom': 'Прочее', +}; + +const _countryLabels = { + 'RU': 'Россия', + 'US': 'США', + 'KZ': 'Казахстан', + 'CY': 'Кипр', + 'NL': 'Нидерланды', +}; + +const dimensionLabels = { + 'asset_class': 'Класс актива', + 'sector': 'Сектор', + 'country': 'Страна', + 'currency': 'Валюта', +}; + +String assetClassLabel(String? value) => assetClassLabels[value] ?? value ?? '—'; + +/// A bucket key as a person reads it. `cash` and `unknown` are the two literals the +/// allocation step emits; everything else is the source's own value. +String bucketLabel(AllocationDimension dimension, String bucket) { + if (bucket == 'cash') return 'Денежные средства'; + if (bucket == 'unknown') return 'Не указано'; + // `.value` is the wire key; `.name` is the Dart identifier the generator invented + // (`assetClass` for `asset_class`), so keying a map off `.name` silently never matches. + return switch (dimension.value) { + 'asset_class' => assetClassLabels[bucket] ?? bucket, + 'country' => _countryLabels[bucket] ?? bucket, + _ => bucket, + }; +} + +String dimensionLabel(AllocationDimension dimension) => + dimensionLabels[dimension.value] ?? dimension.value; + +const periodLabels = { + '1m': '1 мес', + '3m': '3 мес', + '6m': '6 мес', + 'ytd': 'С начала года', + '1y': '1 год', + '3y': '3 года', + 'all': 'Всё время', +}; + +String periodLabel(String period) => periodLabels[period] ?? period; + +const eventKindLabels = { + 'buy': 'Покупка', + 'sell': 'Продажа', + 'dividend': 'Дивиденд', + 'coupon': 'Купон', + 'interest': 'Проценты', + 'tax': 'Налог', + 'tax_refund': 'Возврат налога', + 'commission': 'Комиссия', + 'deposit': 'Пополнение', + 'withdrawal': 'Вывод', + 'transfer_in': 'Ввод бумаг', + 'transfer_out': 'Вывод бумаг', + 'split': 'Сплит', + 'amortization': 'Амортизация', + 'repayment': 'Погашение', + 'fx_exchange': 'Конвертация', + 'other': 'Прочее', +}; + +String eventKindLabel(EventKind kind) => eventKindLabels[kind.value] ?? kind.value; + +/// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is +/// not zero percent. +String formatPercent(String? value, {bool signed = true}) { + if (value == null) return '—'; + final pct = (Decimal.parse(value).toDouble()) * 100; + final sign = signed && pct > 0 ? '+' : ''; + return '$sign${pct.toStringAsFixed(2).replaceAll('.', ',')} %'; +} + +/// `'12,5'` for a quantity, trimming the NUMERIC(24,10) trailing zeros. +String formatQty(String value) { + final d = Decimal.parse(value); + final text = d == d.truncate() ? d.truncate().toString() : d.toString(); + return text.replaceAll('.', ','); +} + +Color? signColor(BuildContext context, String? value) { + if (value == null) return null; + final d = Decimal.parse(value); + if (d == Decimal.zero) return null; + return d > Decimal.zero ? ChartColors.slot3Aqua : ChartColors.slot2Orange; +} + +/// The price-status chip every value on screen depends on: a stale price still produces a +/// number, a missing one produces nothing at all, and both must be visible. +class PriceStatusChip extends StatelessWidget { + const PriceStatusChip({required this.status, required this.priceDate, super.key}); + + final String status; + final DateTime? priceDate; + + @override + Widget build(BuildContext context) { + if (status == 'ok') return const SizedBox.shrink(); + final missing = status == 'missing'; + final scheme = Theme.of(context).colorScheme; + final on = priceDate == null ? '' : ' от ${ruDate(priceDate!)}'; + return Tooltip( + message: missing + ? 'Нет цены — стоимость позиции неизвестна и не входит в итоги' + : 'Цена$on устарела, оценка по последней известной', + child: Icon( + missing ? Icons.help_outline : Icons.schedule, + size: 16, + color: missing ? scheme.error : ChartColors.slot4Yellow, + ), + ); + } +} diff --git a/app/lib/features/portfolio/portfolio_page.dart b/app/lib/features/portfolio/portfolio_page.dart new file mode 100644 index 0000000..7a87036 --- /dev/null +++ b/app/lib/features/portfolio/portfolio_page.dart @@ -0,0 +1,70 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/widgets/async_value_view.dart'; +import 'allocation_tab.dart'; +import 'holdings_tab.dart'; +import 'providers.dart'; + +/// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope. +/// +/// They are tabs rather than two navigation destinations because they answer two halves of +/// the same question, and because a tenth item in the bottom bar would leave 40 px per +/// label on a phone. +class PortfolioPage extends ConsumerWidget { + const PortfolioPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Портфель'), + actions: const [_ScopeSelector(), SizedBox(width: 8)], + bottom: const TabBar( + tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')], + ), + ), + body: const TabBarView(children: [HoldingsTab(), AllocationTab()]), + ), + ); + } +} + +/// Switches every portfolio screen at once. Hidden while there is nothing to choose +/// between — a dropdown with one option is furniture, not a control. +class _ScopeSelector extends ConsumerWidget { + const _ScopeSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scopes = ref.watch(scopesProvider); + final current = ref.watch(scopeProvider); + + return AsyncValueView>( + value: scopes, + data: (rows) { + if (rows.length < 2) return const SizedBox.shrink(); + final known = rows.any((s) => s.scope == current) ? current : rows.first.scope; + return DropdownButtonHideUnderline( + child: DropdownButton( + value: known, + borderRadius: BorderRadius.circular(8), + items: [ + for (final s in rows) + DropdownMenuItem( + value: s.scope, + child: Text(s.name, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (value) { + if (value != null) ref.read(scopeProvider.notifier).state = value; + }, + ), + ); + }, + ); + } +} diff --git a/app/lib/features/portfolio/providers.dart b/app/lib/features/portfolio/providers.dart new file mode 100644 index 0000000..14cd272 --- /dev/null +++ b/app/lib/features/portfolio/providers.dart @@ -0,0 +1,70 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; + +/// The reporting unit every portfolio screen is scoped to: `all`, `account:` or +/// `portfolio:`. Held in one place so switching it on Позиции also switches Аллокация +/// and the instrument card — three screens showing different scopes would be a trap. +final scopeProvider = StateProvider((ref) => 'all'); + +final scopesProvider = FutureProvider.autoDispose>((ref) async { + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsScopes(); + return r.data ?? const []; +}); + +final portfolioSummaryProvider = FutureProvider.autoDispose((ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope); + return r.data!; +}); + +final holdingsProvider = FutureProvider.autoDispose>((ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsHoldings(scope: scope); + return r.data ?? const []; +}); + +final portfolioReturnsProvider = FutureProvider.autoDispose>((ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope); + return r.data ?? const []; +}); + +final allocationProvider = FutureProvider.autoDispose>((ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope); + return r.data ?? const []; +}); + +/// Daily portfolio value for the last year, for the chart on Позиции. +final valueSeriesProvider = FutureProvider.autoDispose>((ref) async { + final scope = ref.watch(scopeProvider); + final now = DateTime.now(); + final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries( + scope: scope, + from: now.subtract(const Duration(days: 365)), + to: now, + ); + return r.data ?? const []; +}); + +final instrumentProvider = + FutureProvider.autoDispose.family((ref, instrumentId) async { + final scope = ref.watch(scopeProvider); + final r = await ref + .watch(apiProvider) + .getInstrumentsApi() + .instrumentsGet(instrumentId: instrumentId, scope: scope); + return r.data!; +}); + +/// Every portfolio provider, refreshed together after a metrics rebuild or a pull-to-refresh. +void invalidatePortfolioProviders(WidgetRef ref) { + ref.invalidate(portfolioSummaryProvider); + ref.invalidate(holdingsProvider); + ref.invalidate(portfolioReturnsProvider); + ref.invalidate(allocationProvider); + ref.invalidate(valueSeriesProvider); + ref.invalidate(instrumentProvider); +} diff --git a/app/lib/features/shell/app_shell.dart b/app/lib/features/shell/app_shell.dart index 1263471..50ed3ea 100644 --- a/app/lib/features/shell/app_shell.dart +++ b/app/lib/features/shell/app_shell.dart @@ -12,6 +12,7 @@ class _Destination { const _destinations = [ _Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'), _Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'), + _Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель'), _Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'), _Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'), _Destination( @@ -36,8 +37,14 @@ class AppShell extends StatelessWidget { final Widget child; int get _selectedIndex { - final i = _destinations.indexWhere((d) => d.path == location); - return i == -1 ? 0 : i; + // longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected + var best = -1; + for (var i = 0; i < _destinations.length; i++) { + final path = _destinations[i].path; + final matches = path == '/' ? location == '/' : location.startsWith(path); + if (matches && (best == -1 || path.length > _destinations[best].path.length)) best = i; + } + return best == -1 ? 0 : best; } void _onSelect(BuildContext context, int index) { diff --git a/app/lib/router.dart b/app/lib/router.dart index da92808..a4d4d63 100644 --- a/app/lib/router.dart +++ b/app/lib/router.dart @@ -8,6 +8,8 @@ import 'features/cashflow/cashflow_page.dart'; import 'features/categories/categories_page.dart'; import 'features/home/home_page.dart'; import 'features/login/login_page.dart'; +import 'features/portfolio/instrument_page.dart'; +import 'features/portfolio/portfolio_page.dart'; import 'features/rules/rules_page.dart'; import 'features/settings/settings_page.dart'; import 'features/shell/app_shell.dart'; @@ -39,6 +41,12 @@ final routerProvider = Provider((ref) { routes: [ GoRoute(path: '/', builder: (_, _) => const HomePage()), GoRoute(path: '/accounts', builder: (_, _) => const AccountsPage()), + GoRoute(path: '/portfolio', builder: (_, _) => const PortfolioPage()), + GoRoute( + path: '/portfolio/instrument/:id', + builder: (_, state) => + InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)), + ), GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()), GoRoute( path: '/categories', diff --git a/app/test/portfolio_test.dart b/app/test/portfolio_test.dart new file mode 100644 index 0000000..d46f221 --- /dev/null +++ b/app/test/portfolio_test.dart @@ -0,0 +1,166 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/widgets/money_text.dart'; +import 'package:fintracker_app/features/portfolio/allocation_tab.dart'; +import 'package:fintracker_app/features/portfolio/holdings_tab.dart'; +import 'package:fintracker_app/features/portfolio/labels.dart'; +import 'package:fintracker_app/features/portfolio/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +HoldingOut holding({ + required int id, + required String ticker, + String? valueRub, + String priceStatus = 'ok', + String? weight, +}) { + return HoldingOut( + accruedInterestRub: null, + assetClass: 'share', + avgCost: '100', + board: 'TQBR', + costCurrency: 'RUB', + costTotalRub: '10000', + currency: 'RUB', + daysHeld: 40, + firstBuyDate: DateTime(2026, 8, 1), + incomeRub: '0', + instrumentId: id, + ldvEligibleQty: '0', + marketPrice: priceStatus == 'missing' ? null : '110', + name: ticker, + priceCurrency: 'RUB', + priceDate: DateTime(2026, 9, 17), + priceStatus: priceStatus, + qty: '100', + realizedPnlRub: '0', + ticker: ticker, + unrealizedPnlNative: valueRub == null ? null : '1000', + unrealizedPnlRub: valueRub == null ? null : '1000', + valueNative: valueRub, + valueRub: valueRub, + weight: weight, + xirr: null, + ); +} + +SummaryOut summary() => SummaryOut( + asOf: DateTime(2026, 9, 18), + cashRub: '5700', + computedAt: DateTime.utc(2026, 9, 18, 10), + holdingCount: 2, + incomeRub: '700', + investedNetRub: '20000', + marketValueRub: '11000', + pnlTotalRub: null, + realizedPnlRub: '0', + returns: const [], + scope: 'all', + staleCount: 0, + totalRub: '16700', + unpricedCount: 1, + ); + +Widget _wrap(Widget child, List overrides) => ProviderScope( + overrides: overrides, + child: MaterialApp(home: Scaffold(body: child)), + ); + +/// The tabs are long scrolling lists and a ListView only builds what fits, so the default +/// 800x600 surface would leave the holdings table unbuilt and every `find` on it empty. +Future pumpTall(WidgetTester tester, Widget widget) async { + tester.view.physicalSize = const Size(1400, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (tester) async { + await pumpTall(tester, _wrap( + const HoldingsTab(), + [ + portfolioSummaryProvider.overrideWith((ref) => summary()), + valueSeriesProvider.overrideWith((ref) => const []), + portfolioReturnsProvider.overrideWith((ref) => const []), + holdingsProvider.overrideWith((ref) => [ + holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'), + holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'), + ]), + ], + )); + + expect(find.text('GAZP'), findsOneWidget); + expect(find.text('SIBN6P4'), findsOneWidget); + expect(find.text(MoneyText.format('11000', 'RUB')), findsWidgets); + // the unpriced row is dashes all the way across — price, value, weight, P&L, XIRR — + // and nowhere on the screen is an unknown number rendered as 0 ₽ + expect(find.text('—'), findsAtLeastNWidgets(5)); + expect(find.text(MoneyText.format('0', 'RUB')), findsNothing); + }); + + testWidgets('Позиции show the total profit as «—» while a price is missing', (tester) async { + await pumpTall(tester, _wrap( + const HoldingsTab(), + [ + portfolioSummaryProvider.overrideWith((ref) => summary()), + valueSeriesProvider.overrideWith((ref) => const []), + portfolioReturnsProvider.overrideWith((ref) => const []), + holdingsProvider.overrideWith((ref) => const []), + ], + )); + + expect(find.text('Прибыль'), findsOneWidget); + expect(find.text('часть позиций без цены'), findsOneWidget); + }); + + testWidgets('Аллокация labels the cash and unknown buckets in Russian', (tester) async { + await pumpTall(tester, _wrap( + const AllocationTab(), + [ + allocationProvider.overrideWith((ref) => [ + AllocationBucket( + bucket: 'share', + dimension: AllocationDimension.assetClass, + holdingCount: 1, + valueRub: '11000', + weight: '0.6587', + ), + AllocationBucket( + bucket: 'cash', + dimension: AllocationDimension.assetClass, + holdingCount: 0, + valueRub: '5700', + weight: '0.3413', + ), + ]), + ], + )); + + expect(find.text('Класс актива'), findsOneWidget); + expect(find.text('Акции'), findsOneWidget); + expect(find.text('Денежные средства'), findsOneWidget); + expect(find.text('65,87 %'), findsOneWidget); + }); + + test('formatPercent keeps null distinct from zero', () { + expect(formatPercent(null), '—'); + expect(formatPercent('0'), '0,00 %'); + expect(formatPercent('0.1401'), '+14,01 %'); + expect(formatPercent('-0.128'), '-12,80 %'); + expect(formatPercent('0.65', signed: false), '65,00 %'); + }); + + test('formatQty trims the NUMERIC(24,10) tail', () { + expect(formatQty('215.0000000000'), '215'); + expect(formatQty('12.5000000000'), '12,5'); + }); + + test('bucketLabel falls back to the source value it does not know', () { + expect(bucketLabel(AllocationDimension.country, 'RU'), 'Россия'); + expect(bucketLabel(AllocationDimension.country, 'unknown'), 'Не указано'); + expect(bucketLabel(AllocationDimension.sector, 'oil_and_gas'), 'oil_and_gas'); + }); +}