Files
fin-tracker/app/lib/features/portfolio/holdings_table.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

467 lines
14 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: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<HoldingOut> rows;
@override
State<HoldingsTable> createState() => _HoldingsTableState();
}
class _HoldingsTableState extends State<HoldingsTable> {
HoldingsPreset _preset = HoldingsPreset.mine;
String _query = '';
List<HoldingOut> 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<double>(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,
),
),
),
],
),
);
}
}