Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации: они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх экранов — три экрана с разными scope были бы ловушкой. Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для общей прибыли, когда в портфеле есть хоть одна неоценённая бумага. Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя (assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает никогда. Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
372 lines
12 KiB
Dart
372 lines
12 KiB
Dart
import 'package:decimal/decimal.dart';
|
||
import 'package:fintracker_api/fintracker_api.dart';
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../core/theme/chart_colors.dart';
|
||
import '../../core/utils/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,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|