Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
149 lines
4.8 KiB
Dart
149 lines
4.8 KiB
Dart
import 'package:decimal/decimal.dart';
|
|
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/theme/chart_colors.dart';
|
|
import '../../core/utils/ru_date.dart';
|
|
|
|
/// Russian labels for the keys the API sends as stable identifiers.
|
|
///
|
|
/// The backend deliberately stores keys, not labels — `bucket` is `bond`, `cash`,
|
|
/// `unknown`, a country code or a currency — so the language lives here, on the one side
|
|
/// that has a language at all.
|
|
const assetClassLabels = {
|
|
'share': 'Акции',
|
|
'bond': 'Облигации',
|
|
'etf': 'Фонды',
|
|
'fund': 'ПИФы',
|
|
'currency': 'Валюта',
|
|
'index': 'Индексы',
|
|
'deposit': 'Депозиты',
|
|
'real_estate': 'Недвижимость',
|
|
'crypto': 'Криптовалюта',
|
|
'custom': 'Прочее',
|
|
};
|
|
|
|
const _countryLabels = {
|
|
'RU': 'Россия',
|
|
'US': 'США',
|
|
'KZ': 'Казахстан',
|
|
'CY': 'Кипр',
|
|
'NL': 'Нидерланды',
|
|
};
|
|
|
|
const dimensionLabels = {
|
|
'asset_class': 'Класс актива',
|
|
'sector': 'Сектор',
|
|
'country': 'Страна',
|
|
'currency': 'Валюта',
|
|
};
|
|
|
|
String assetClassLabel(String? value) =>
|
|
assetClassLabels[value] ?? value ?? '—';
|
|
|
|
/// A bucket key as a person reads it. `cash` and `unknown` are the two literals the
|
|
/// allocation step emits; everything else is the source's own value.
|
|
String bucketLabel(AllocationDimension dimension, String bucket) {
|
|
if (bucket == 'cash') return 'Денежные средства';
|
|
if (bucket == 'unknown') return 'Не указано';
|
|
// `.value` is the wire key; `.name` is the Dart identifier the generator invented
|
|
// (`assetClass` for `asset_class`), so keying a map off `.name` silently never matches.
|
|
return switch (dimension.value) {
|
|
'asset_class' => assetClassLabels[bucket] ?? bucket,
|
|
'country' => _countryLabels[bucket] ?? bucket,
|
|
_ => bucket,
|
|
};
|
|
}
|
|
|
|
String dimensionLabel(AllocationDimension dimension) =>
|
|
dimensionLabels[dimension.value] ?? dimension.value;
|
|
|
|
const periodLabels = {
|
|
'1m': '1 мес',
|
|
'3m': '3 мес',
|
|
'6m': '6 мес',
|
|
'ytd': 'С начала года',
|
|
'1y': '1 год',
|
|
'3y': '3 года',
|
|
'all': 'Всё время',
|
|
};
|
|
|
|
String periodLabel(String period) => periodLabels[period] ?? period;
|
|
|
|
const eventKindLabels = {
|
|
'buy': 'Покупка',
|
|
'sell': 'Продажа',
|
|
'dividend': 'Дивиденд',
|
|
'coupon': 'Купон',
|
|
'interest': 'Проценты',
|
|
'tax': 'Налог',
|
|
'tax_refund': 'Возврат налога',
|
|
'commission': 'Комиссия',
|
|
'deposit': 'Пополнение',
|
|
'withdrawal': 'Вывод',
|
|
'transfer_in': 'Ввод бумаг',
|
|
'transfer_out': 'Вывод бумаг',
|
|
'split': 'Сплит',
|
|
'amortization': 'Амортизация',
|
|
'repayment': 'Погашение',
|
|
'fx_exchange': 'Конвертация',
|
|
'other': 'Прочее',
|
|
};
|
|
|
|
String eventKindLabel(EventKind kind) =>
|
|
eventKindLabels[kind.value] ?? kind.value;
|
|
|
|
/// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is
|
|
/// not zero percent.
|
|
String formatPercent(String? value, {bool signed = true}) {
|
|
if (value == null) return '—';
|
|
final pct = (Decimal.parse(value).toDouble()) * 100;
|
|
final sign = signed && pct > 0 ? '+' : '';
|
|
return '$sign${pct.toStringAsFixed(2).replaceAll('.', ',')} %';
|
|
}
|
|
|
|
/// `'12,5'` for a quantity, trimming the NUMERIC(24,10) trailing zeros.
|
|
String formatQty(String value) {
|
|
final d = Decimal.parse(value);
|
|
final text = d == d.truncate() ? d.truncate().toString() : d.toString();
|
|
return text.replaceAll('.', ',');
|
|
}
|
|
|
|
Color? signColor(BuildContext context, String? value) {
|
|
if (value == null) return null;
|
|
final d = Decimal.parse(value);
|
|
if (d == Decimal.zero) return null;
|
|
return d > Decimal.zero ? ChartColors.gain : ChartColors.loss;
|
|
}
|
|
|
|
/// The price-status chip every value on screen depends on: a stale price still produces a
|
|
/// number, a missing one produces nothing at all, and both must be visible.
|
|
class PriceStatusChip extends StatelessWidget {
|
|
const PriceStatusChip({
|
|
required this.status,
|
|
required this.priceDate,
|
|
super.key,
|
|
});
|
|
|
|
final String status;
|
|
final DateTime? priceDate;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (status == 'ok') return const SizedBox.shrink();
|
|
final missing = status == 'missing';
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final on = priceDate == null ? '' : ' от ${ruDate(priceDate!)}';
|
|
return Tooltip(
|
|
message: missing
|
|
? 'Нет цены — стоимость позиции неизвестна и не входит в итоги'
|
|
: 'Цена$on устарела, оценка по последней известной',
|
|
child: Icon(
|
|
missing ? Icons.help_outline : Icons.schedule,
|
|
size: 16,
|
|
color: missing ? scheme.error : ChartColors.slot4Yellow,
|
|
),
|
|
);
|
|
}
|
|
}
|