feat(app): экраны портфеля — позиции, аллокация и карточка инструмента
Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации: они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх экранов — три экрана с разными scope были бы ловушкой. Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для общей прибыли, когда в портфеле есть хоть одна неоценённая бумага. Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя (assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает никогда. Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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.slot3Aqua : ChartColors.slot2Orange;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user