feat(app): экраны портфеля — позиции, аллокация и карточка инструмента
Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации: они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх экранов — три экрана с разными scope были бы ловушкой. Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для общей прибыли, когда в портфеле есть хоть одна неоценённая бумага. Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя (assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает никогда. Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
This commit is contained in:
@@ -107,6 +107,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
final runway = ref.watch(runwayProvider);
|
final runway = ref.watch(runwayProvider);
|
||||||
final status = ref.watch(metricsStatusProvider);
|
final status = ref.watch(metricsStatusProvider);
|
||||||
final dataQuality = ref.watch(dataQualityProvider);
|
final dataQuality = ref.watch(dataQualityProvider);
|
||||||
|
final portfolio = ref.watch(portfolioSummaryHomeProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -175,6 +176,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
AsyncValueView(
|
||||||
value: runway,
|
value: runway,
|
||||||
onRetry: () => ref.invalidate(runwayProvider),
|
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('.', ',')} %';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,4 +63,17 @@ void invalidateHomeProviders(WidgetRef ref) {
|
|||||||
ref.invalidate(runwayProvider);
|
ref.invalidate(runwayProvider);
|
||||||
ref.invalidate(metricsStatusProvider);
|
ref.invalidate(metricsStatusProvider);
|
||||||
ref.invalidate(dataQualityProvider);
|
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<SummaryOut?>((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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = <AllocationDimension, List<AllocationBucket>>{};
|
||||||
|
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<AllocationBucket> 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<AllocationBucket> 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<AllocationBucket> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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)));
|
||||||
|
}
|
||||||
|
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<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: 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<HoldingOut> 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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <String>[
|
||||||
|
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<PricePoint> 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<LotOut> 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<EventOut> 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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<ScopeOut>>(
|
||||||
|
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<String>(
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:<id>` or
|
||||||
|
/// `portfolio:<id>`. 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<String>((ref) => 'all');
|
||||||
|
|
||||||
|
final scopesProvider = FutureProvider.autoDispose<List<ScopeOut>>((ref) async {
|
||||||
|
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsScopes();
|
||||||
|
return r.data ?? const [];
|
||||||
|
});
|
||||||
|
|
||||||
|
final portfolioSummaryProvider = FutureProvider.autoDispose<SummaryOut>((ref) async {
|
||||||
|
final scope = ref.watch(scopeProvider);
|
||||||
|
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope);
|
||||||
|
return r.data!;
|
||||||
|
});
|
||||||
|
|
||||||
|
final holdingsProvider = FutureProvider.autoDispose<List<HoldingOut>>((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<List<ReturnsOut>>((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<List<AllocationBucket>>((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<List<ValueDay>>((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<InstrumentDetail, int>((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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ class _Destination {
|
|||||||
const _destinations = [
|
const _destinations = [
|
||||||
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
|
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
|
||||||
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
|
_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('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
|
||||||
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
|
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
|
||||||
_Destination(
|
_Destination(
|
||||||
@@ -36,8 +37,14 @@ class AppShell extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
int get _selectedIndex {
|
int get _selectedIndex {
|
||||||
final i = _destinations.indexWhere((d) => d.path == location);
|
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
|
||||||
return i == -1 ? 0 : i;
|
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) {
|
void _onSelect(BuildContext context, int index) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'features/cashflow/cashflow_page.dart';
|
|||||||
import 'features/categories/categories_page.dart';
|
import 'features/categories/categories_page.dart';
|
||||||
import 'features/home/home_page.dart';
|
import 'features/home/home_page.dart';
|
||||||
import 'features/login/login_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/rules/rules_page.dart';
|
||||||
import 'features/settings/settings_page.dart';
|
import 'features/settings/settings_page.dart';
|
||||||
import 'features/shell/app_shell.dart';
|
import 'features/shell/app_shell.dart';
|
||||||
@@ -39,6 +41,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (_, _) => const HomePage()),
|
GoRoute(path: '/', builder: (_, _) => const HomePage()),
|
||||||
GoRoute(path: '/accounts', builder: (_, _) => const AccountsPage()),
|
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: '/cashflow', builder: (_, _) => const CashflowPage()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/categories',
|
path: '/categories',
|
||||||
|
|||||||
@@ -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<Override> 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<void> 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 <ValueDay>[]),
|
||||||
|
portfolioReturnsProvider.overrideWith((ref) => const <ReturnsOut>[]),
|
||||||
|
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 <ValueDay>[]),
|
||||||
|
portfolioReturnsProvider.overrideWith((ref) => const <ReturnsOut>[]),
|
||||||
|
holdingsProvider.overrideWith((ref) => const <HoldingOut>[]),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user