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, ], ), ), ); } }