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

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,
),
);
}
}