feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
@@ -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,
),
),
),
],
),
);
}
}