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,222 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cached.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/money_text.dart';
import '../home/providers.dart' show scopeCardsProvider;
import '../../core/widgets/help_tip.dart';
import 'labels.dart';
import 'providers.dart';
/// The four figures on top of Аналитика → Общее: what the scope is worth, what it earned, its
/// return and the passive income it should bring in a year. Everything comes from the same
/// providers as Портфель and the home cards, so the numbers cannot disagree between screens.
class OverviewTiles extends ConsumerWidget {
const OverviewTiles({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(portfolioSummaryProvider);
final scope = ref.watch(scopeProvider);
final card = ref
.watch(scopeCardsProvider)
.valueOrNull
?.data
.where((c) => c.scope == scope)
.firstOrNull;
return AsyncValueView<Cached<SummaryOut>>(
value: summary,
onRetry: () => ref.invalidate(portfolioSummaryProvider),
data: (cached) => _Grid(summary: cached.data, card: card),
);
}
}
class _Grid extends StatelessWidget {
const _Grid({required this.summary, required this.card});
final SummaryOut summary;
final ScopeCardOut? card;
static const _gap = 16.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull;
final all = summary.returns.where((r) => r.period == 'all').firstOrNull;
final xirr = card?.xirr ?? all?.xirr;
final pnl = summary.pnlTotalRub;
final pnlShare =
pnl != null && Decimal.parse(summary.investedNetRub) > Decimal.zero
? (Decimal.parse(pnl) / Decimal.parse(summary.investedNetRub))
.toDecimal(scaleOnInfinitePrecision: 10)
.toString()
: null;
final day = card?.dayChangeRub;
final income = card?.incomeYearRub;
Widget muted(String text, {Color? color}) => Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: color ?? scheme.onSurfaceVariant,
),
);
final tiles = <Widget>[
_Tile(
icon: Icons.account_balance_wallet_outlined,
label: 'Стоимость',
value: Text(MoneyText.format(summary.totalRub, 'RUB')),
note: muted(
'${MoneyText.format(summary.investedNetRub, 'RUB')} вложено',
),
),
_Tile(
icon: Icons.show_chart,
label: 'Прибыль',
value: pnl == null
? const Text('')
: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
(Decimal.parse(pnl) > Decimal.zero ? '+' : '') +
MoneyText.format(pnl, 'RUB'),
style: TextStyle(color: signColor(context, pnl)),
),
if (pnlShare != null)
Text(
' ${formatPercent(pnlShare)}',
style: theme.textTheme.titleSmall?.copyWith(
color: signColor(context, pnl),
),
),
],
),
note: day == null
? muted(pnl == null ? 'часть позиций без цены' : 'за день —')
: muted(
'${Decimal.parse(day) > Decimal.zero ? '+' : ''}${MoneyText.format(day, 'RUB')}'
'${card?.dayChangePct == null ? '' : ' ${formatPercent(card!.dayChangePct)}'} за день',
color: signColor(context, day),
),
),
_Tile(
icon: Icons.percent,
label: 'Доходность',
value: Text(
formatPercent(xirr, signed: false),
style: TextStyle(color: signColor(context, xirr)),
),
note: muted(
yearly?.twr == null
? 'денежно-взвешенная, с начала'
: 'рост активов ${formatPercent(yearly!.twr)} за год',
),
),
_Tile(
icon: Icons.savings_outlined,
label: 'Пассивный доход',
value: Text(
card?.incomeYearPct == null
? ''
: formatPercent(card!.incomeYearPct, signed: false),
),
note: muted(
income == null || Decimal.parse(income) == Decimal.zero
? 'прогноза выплат пока нет'
: '${MoneyText.format(income, 'RUB')} в год',
color: income == null || Decimal.parse(income) == Decimal.zero
? null
: ChartColors.gain,
),
),
];
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final columns = width >= 1000 ? 4 : (width >= 560 ? 2 : 1);
final itemWidth = (width - _gap * (columns - 1)) / columns;
return Wrap(
spacing: _gap,
runSpacing: _gap,
children: [
for (final t in tiles) SizedBox(width: itemWidth, child: t),
],
);
},
);
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.value,
required this.note,
});
final IconData icon;
final String label;
final Widget value;
final Widget note;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: scheme.primary),
),
const SizedBox(width: 10),
TermLabel(
label,
hint: label == 'Стоимость'
? 'Стоимость — всё сразу: бумаги по последним ценам плюс деньги на счёте. '
'Ниже — сколько вы вложили (пополнения минус выводы).'
: null,
style: theme.textTheme.bodyLarge?.copyWith(
color: scheme.onSurface,
),
),
],
),
const SizedBox(height: 16),
DefaultTextStyle.merge(
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w500,
),
child: value,
),
const SizedBox(height: 8),
note,
],
),
),
);
}
}