Files
fin-tracker/app/lib/features/portfolio/instrument_page.dart
T
Dmitry 1d7769ffbf feat(app): экраны портфеля — позиции, аллокация и карточка инструмента
Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации:
они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы
по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх
экранов — три экрана с разными scope были бы ловушкой.

Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в
итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для
общей прибыли, когда в портфеле есть хоть одна неоценённая бумага.

Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя
(assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает
никогда.

Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView
строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
2026-09-18 14:21:06 +03:00

372 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package: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,
],
),
),
);
}
}