feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user