feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user