import 'package:decimal/decimal.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../core/widgets/asset_icon.dart'; import '../../core/widgets/money_text.dart'; import '../../core/widgets/help_tip.dart'; import 'labels.dart'; /// Which columns the table shows. Each preset answers one question about the same rows, the /// way Snowball's «Мои активы | Общее | Дивиденды | Прибыль | Облигации» tabs do. enum HoldingsPreset { mine('Мои активы'), overall('Общее'), dividends('Дивиденды'), profit('Прибыль'), bonds('Облигации'); const HoldingsPreset(this.label); final String label; } /// A column: a header, a width, and how a position renders in it. A cell is one or two lines — /// the figure and, muted below it, the same figure per unit or as a share. class _Col { const _Col(this.title, this.width, this.cell, {this.alignEnd = true}); final String title; final double width; final Widget Function(BuildContext context, HoldingOut h) cell; final bool alignEnd; } const _dash = Text('—'); /// A cell whose figure is unknown is a dash, never a zero: the position is absent from the /// totals, and 0 ₽ would read as «worthless» instead of «unknown». Widget _money( String? v, { String currency = 'RUB', TextStyle? style, bool signed = false, }) { if (v == null) return _dash; final d = Decimal.parse(v); final text = MoneyText.format(v, currency); return Text(signed && d > Decimal.zero ? '+$text' : text, style: style); } Widget _stack(BuildContext context, Widget top, Widget? bottom) { final muted = Theme.of(context).textTheme.bodySmall ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); return Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ DefaultTextStyle.merge( style: const TextStyle(fontWeight: FontWeight.w600), child: top, ), if (bottom != null) DefaultTextStyle.merge(style: muted, child: bottom), ], ); } /// `share` of a decimal-string pair, or null when the base is missing or not positive. String? _ratio(String? part, String? base) { if (part == null || base == null) return null; final b = Decimal.parse(base); if (b <= Decimal.zero) return null; return (Decimal.parse(part) / b) .toDecimal(scaleOnInfinitePrecision: 10) .toString(); } Widget _signedPercent(BuildContext context, String? share) { if (share == null) return _dash; final color = signColor(context, share); final d = Decimal.parse(share); final arrow = d > Decimal.zero ? Icons.arrow_drop_up : d < Decimal.zero ? Icons.arrow_drop_down : null; return Row( mainAxisSize: MainAxisSize.min, children: [ if (arrow != null) Icon(arrow, size: 18, color: color), Text( formatPercent(share, signed: false).replaceAll('-', ''), style: TextStyle(color: color), ), ], ); } Widget _pnl(BuildContext context, String? amount, String? share) { if (amount == null) return _dash; return _stack( context, _money( amount, signed: true, style: TextStyle(color: signColor(context, amount)), ), _signedPercent(context, share), ); } final _asset = _Col('Актив', 260, (context, h) { final theme = Theme.of(context); final title = h.name; final ticker = h.ticker; return Row( children: [ AssetIcon( assetClass: h.assetClass, logoUrl: h.logoUrl, logoColor: h.logoColor, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( title, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w500), ), Row( children: [ if (ticker != null && ticker != title) Flexible( child: Text( ticker, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate), ], ), ], ), ), ], ); }, alignEnd: false); final _qty = _Col('Кол-во', 90, (c, h) => Text(formatQty(h.qty))); final _invested = _Col( 'Вложено', 140, (c, h) => _stack( c, _money(h.costTotalRub), h.avgCost == null ? null : _money(h.avgCost, currency: h.costCurrency ?? h.currency), ), ); final _value = _Col( 'Текущая стоимость', 170, (c, h) => h.valueRub == null ? _dash : _stack( c, _money(h.valueRub), h.marketPrice == null ? null : _money(h.marketPrice, currency: h.priceCurrency ?? h.currency), ), ); final _income = _Col('Дивиденды', 120, (c, h) => _money(_nonZero(h.incomeRub))); final _incomeYield = _Col( 'Див. доходность', 130, (c, h) => Text( formatPercent(_nonZero(_ratio(h.incomeRub, h.costTotalRub)), signed: false), ), ); final _profit = _Col( 'Прибыль', 130, (c, h) => _pnl(c, h.unrealizedPnlRub, _ratio(h.unrealizedPnlRub, h.costTotalRub)), ); final _weight = _Col( 'Доля в портфеле', 130, (c, h) => Text(formatPercent(h.weight, signed: false)), ); final _xirr = _Col( 'Доходность', 120, (c, h) => Text( formatPercent(h.xirr), style: TextStyle(color: signColor(c, h.xirr)), ), ); final _held = _Col( 'В портфеле', 120, (c, h) => Text(h.daysHeld == null ? '—' : '${h.daysHeld} дн.'), ); final _realized = _Col( 'Реализовано', 130, (c, h) => _money( h.realizedPnlRub, signed: true, style: TextStyle(color: signColor(c, h.realizedPnlRub)), ), ); final _accrued = _Col( 'НКД', 110, (c, h) => _money(_nonZero(h.accruedInterestRub)), ); /// A payout or accrual of zero is «none», shown as a dash; only a real amount is a figure. String? _nonZero(String? v) => v == null || Decimal.parse(v) == Decimal.zero ? null : v; final _columns = { HoldingsPreset.mine: [ _asset, _qty, _invested, _value, _income, _incomeYield, _profit, _weight, ], HoldingsPreset.overall: [ _asset, _qty, _invested, _value, _profit, _xirr, _weight, _held, ], HoldingsPreset.dividends: [_asset, _qty, _income, _incomeYield], HoldingsPreset.profit: [_asset, _profit, _realized, _income, _xirr], HoldingsPreset.bonds: [ _asset, _qty, _invested, _value, _accrued, _profit, _xirr, ], }; /// Позиции as a table: preset tabs, a search box, and one row per holding. A tap opens the /// instrument card. class HoldingsTable extends StatefulWidget { const HoldingsTable({required this.rows, super.key}); final List rows; @override State createState() => _HoldingsTableState(); } class _HoldingsTableState extends State { HoldingsPreset _preset = HoldingsPreset.mine; String _query = ''; List get _visible { final q = _query.trim().toLowerCase(); return [ for (final h in widget.rows) if ((_preset != HoldingsPreset.bonds || h.assetClass == 'bond') && (q.isEmpty || h.name.toLowerCase().contains(q) || (h.ticker ?? '').toLowerCase().contains(q))) h, ]; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; final columns = _columns[_preset]!; final rows = _visible; const pad = 32.0; final natural = columns.fold(0, (sum, c) => sum + c.width); return LayoutBuilder( builder: (context, constraints) { // spread the columns over the card when there is room; scroll sideways when there is not final room = constraints.maxWidth.isFinite ? constraints.maxWidth - pad : natural; final scale = room > natural ? room / natural : 1.0; final tableWidth = natural * scale + pad; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: double.infinity, child: Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, alignment: WrapAlignment.spaceBetween, children: [ Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final p in HoldingsPreset.values) InkWell( borderRadius: BorderRadius.circular(6), onTap: () => setState(() => _preset = p), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), child: Text( p.label, style: TextStyle( color: p == _preset ? scheme.onSurface : scheme.onSurfaceVariant, fontWeight: p == _preset ? FontWeight.w700 : FontWeight.w400, ), ), ), ), ], ), SizedBox( width: 260, child: TextField( onChanged: (v) => setState(() => _query = v), decoration: const InputDecoration( hintText: 'Найти…', isDense: true, prefixIcon: Icon(Icons.search, size: 20), ), ), ), ], ), ), const SizedBox(height: 12), SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( width: tableWidth, child: Column( children: [ _HeaderRow(columns: columns, scale: scale), Divider(color: scheme.outlineVariant), if (rows.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Text('Ничего не найдено'), ), for (final h in rows) InkWell( onTap: () => context.push( '/portfolio/instrument/${h.instrumentId}', ), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: scheme.outlineVariant), ), ), child: Row( children: [ for (final c in columns) SizedBox( width: c.width * scale, child: Align( alignment: c.alignEnd ? Alignment.centerRight : Alignment.centerLeft, child: Padding( padding: EdgeInsets.only( right: c.alignEnd ? 8 : 0, ), child: c.cell(context, h), ), ), ), ], ), ), ), ], ), ), ), ], ); }, ); } } class _HeaderRow extends StatelessWidget { const _HeaderRow({required this.columns, required this.scale}); final List<_Col> columns; final double scale; @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.labelMedium ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ for (final c in columns) SizedBox( width: c.width * scale, child: Padding( padding: EdgeInsets.only(right: c.alignEnd ? 8 : 0), child: TermLabel( c.title, textAlign: c.alignEnd ? TextAlign.end : TextAlign.start, alignment: c.alignEnd ? MainAxisAlignment.end : MainAxisAlignment.start, style: style, ), ), ), ], ), ); } }