import 'package:decimal/decimal.dart'; import 'package:dio/dio.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/api/api_client.dart'; import '../../core/auth/auth_controller.dart'; import '../../core/theme/chart_colors.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/asset_icon.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/help_tip.dart'; import 'instrument_edit_dialog.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; Future _edit( BuildContext context, WidgetRef ref, InstrumentOut instrument, ) async { final patch = await showDialog( context: context, builder: (_) => InstrumentEditDialog(instrument: instrument), ); if (patch == null || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); try { final api = ref.read(apiProvider); await api.getInstrumentsApi().instrumentsPatch( instrumentId: instrumentId, instrumentPatch: patch, ); invalidatePortfolioProviders(ref); try { // the asset class feeds the allocation metrics; the lot only the live rebalancing await api.getMetricsApi().metricsRefresh(); } on DioException { // the next scheduled or manual refresh picks the change up } } on DioException catch (e) { messenger.showSnackBar(SnackBar(content: Text(problemMessage(e)))); } } @override Widget build(BuildContext context, WidgetRef ref) { final detail = ref.watch(instrumentProvider(instrumentId)); final instrument = detail.valueOrNull?.data.instrument; return Scaffold( appBar: AppBar( title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'), actions: [ if (instrument != null) IconButton( tooltip: 'Редактировать', icon: const Icon(Icons.edit_outlined), onPressed: () => _edit(context, ref, instrument), ), ], ), body: AsyncValueView( value: detail, onRetry: () => ref.invalidate(instrumentProvider(instrumentId)), data: (cached) { final d = cached.data; return ListView( padding: const EdgeInsets.all(16), children: [ if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!), _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: [ Row( children: [ AssetIcon( assetClass: instrument.assetClass, logoUrl: instrument.logoUrl, logoColor: instrument.logoColor, size: 48, ), const SizedBox(width: 16), Expanded( 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: [ TermLabel( 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: FlDotData(show: prices.length < 2), ), ], ), ), ); } } 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, ], ), ), ); } }