feat(app): редизайн Обзора под новый визуальный язык

Локальные _StatTile/_ChartCard заменены на общие StatTile/SectionCard,
блоки капитала/месяца/инвестиций получили SectionHeader, ряды плиток
переведены с Wrap на TileCarousel. Заодно цвет «ок» в чипе качества
данных переведён с хардкод Colors.green на ChartColors.slot3Aqua —
тот же цвет, что signColor уже использует для положительного знака.
This commit is contained in:
Dmitry
2026-09-19 15:31:47 +03:00
parent 8d404ce7d3
commit e4a64cd1df
+53 -99
View File
@@ -13,7 +13,10 @@ import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart'; import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import '../../core/widgets/section_header.dart';
import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/stale_banner.dart';
import '../../core/widgets/tile_carousel.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client.dart';
import '../health/data_quality_list.dart' show severityColor; import '../health/data_quality_list.dart' show severityColor;
import 'providers.dart'; import 'providers.dart';
@@ -112,7 +115,7 @@ class _HomePageState extends ConsumerState<HomePage> {
avatar: Icon( avatar: Icon(
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined, rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
size: 18, size: 18,
color: rows.isEmpty ? Colors.green : severityColor(rows.first.severity), color: rows.isEmpty ? ChartColors.slot3Aqua : severityColor(rows.first.severity),
), ),
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'), label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
// Health page renders the same findings via DataQualityList — no // Health page renders the same findings via DataQualityList — no
@@ -123,43 +126,51 @@ class _HomePageState extends ConsumerState<HomePage> {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 20),
const SectionHeader(title: 'Капитал'),
const SizedBox(height: 12),
AsyncValueView( AsyncValueView(
value: breakdown, value: breakdown,
onRetry: () => ref.invalidate(netWorthBreakdownProvider), onRetry: () => ref.invalidate(netWorthBreakdownProvider),
data: (cached) => _NetWorthTiles(breakdown: cached.data), data: (cached) => _NetWorthTiles(breakdown: cached.data),
), ),
const SizedBox(height: 24),
const SectionHeader(title: 'Этот месяц'),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( AsyncValueView(
children: [
Expanded(
child: AsyncValueView(
value: thisMonth, value: thisMonth,
onRetry: () => ref.invalidate(cashflowThisMonthProvider), onRetry: () => ref.invalidate(cashflowThisMonthProvider),
data: (cached) => _MonthTiles(month: cached.data), data: (cached) => _MonthTiles(month: cached.data),
), ),
),
],
),
const SizedBox(height: 12),
AsyncValueView(
value: portfolio,
onRetry: () => ref.invalidate(portfolioSummaryHomeProvider),
data: (cached) =>
cached.data == null ? const SizedBox.shrink() : _PortfolioTiles(summary: cached.data!),
),
const SizedBox(height: 12), const SizedBox(height: 12),
AsyncValueView( AsyncValueView(
value: runway, value: runway,
onRetry: () => ref.invalidate(runwayProvider), onRetry: () => ref.invalidate(runwayProvider),
data: (cached) => _RunwayTile(runway: cached.data), data: (cached) => TileCarousel(children: [_RunwayTile(runway: cached.data)]),
), ),
AsyncValueView(
value: portfolio,
onRetry: () => ref.invalidate(portfolioSummaryHomeProvider),
data: (cached) => cached.data == null
? const SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24), const SizedBox(height: 24),
const SectionHeader(title: 'Инвестиции'),
const SizedBox(height: 12),
_PortfolioTiles(summary: cached.data!),
],
),
),
const SizedBox(height: 28),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final wide = constraints.maxWidth >= 900; final wide = constraints.maxWidth >= 900;
final netWorthChart = _ChartCard( final netWorthChart = SectionCard(
title: 'Капитал за 365 дней', title: 'Капитал за 365 дней',
child: SizedBox(
height: 200,
child: AsyncValueView( child: AsyncValueView(
value: series, value: series,
onRetry: () => ref.invalidate(netWorthSeriesProvider), onRetry: () => ref.invalidate(netWorthSeriesProvider),
@@ -167,9 +178,12 @@ class _HomePageState extends ConsumerState<HomePage> {
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
: _NetWorthChart(rows: cached.data), : _NetWorthChart(rows: cached.data),
), ),
),
); );
final cashflowChart = _ChartCard( final cashflowChart = SectionCard(
title: 'Доход и расход, 12 месяцев', title: 'Доход и расход, 12 месяцев',
child: SizedBox(
height: 200,
child: AsyncValueView( child: AsyncValueView(
value: last12, value: last12,
onRetry: () => ref.invalidate(cashflowLast12Provider), onRetry: () => ref.invalidate(cashflowLast12Provider),
@@ -177,6 +191,7 @@ class _HomePageState extends ConsumerState<HomePage> {
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.') ? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
: _IncomeExpenseChart(rows: cached.data), : _IncomeExpenseChart(rows: cached.data),
), ),
),
); );
if (wide) { if (wide) {
return Row( return Row(
@@ -209,49 +224,19 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
} }
class _StatTile extends StatelessWidget {
const _StatTile({required this.label, required this.value});
final String label;
final Widget value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 168,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!, child: value),
],
),
),
),
);
}
}
class _NetWorthTiles extends StatelessWidget { class _NetWorthTiles extends StatelessWidget {
const _NetWorthTiles({required this.breakdown}); const _NetWorthTiles({required this.breakdown});
final NetWorthBreakdown breakdown; final NetWorthBreakdown breakdown;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return TileCarousel(children: [
spacing: 12, StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
runSpacing: 12, StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')),
children: [ StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')),
_StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')), StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
_StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')), StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
_StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')), ]);
_StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
_StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
],
);
} }
} }
@@ -265,15 +250,11 @@ class _MonthTiles extends StatelessWidget {
return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.'); return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.');
} }
final rateText = month!.savingsRate == null ? '' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%'; final rateText = month!.savingsRate == null ? '' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
return Wrap( return TileCarousel(children: [
spacing: 12, StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
runSpacing: 12, StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
children: [ StatTile(label: 'Норма сбережений', value: Text(rateText)),
_StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')), ]);
_StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
_StatTile(label: 'Норма сбережений', value: Text(rateText)),
],
);
} }
} }
@@ -286,30 +267,7 @@ class _RunwayTile extends StatelessWidget {
final text = runway.runwayMonths == null final text = runway.runwayMonths == null
? '' ? ''
: '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.'; : '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.';
return _StatTile(label: 'Запас хода (runway)', value: Text(text)); return StatTile(label: 'Запас хода (runway)', value: Text(text));
}
}
class _ChartCard extends StatelessWidget {
const _ChartCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
SizedBox(height: 200, child: child),
],
),
),
);
} }
} }
@@ -464,31 +422,27 @@ class _PortfolioTiles extends StatelessWidget {
return InkWell( return InkWell(
onTap: () => context.go('/portfolio'), onTap: () => context.go('/portfolio'),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Wrap( child: TileCarousel(children: [
spacing: 12, StatTile(
runSpacing: 12,
children: [
_StatTile(
label: 'Портфель', label: 'Портфель',
value: MoneyText(summary.totalRub, currency: 'RUB'), value: MoneyText(summary.totalRub, currency: 'RUB'),
), ),
_StatTile( StatTile(
label: 'Прибыль', label: 'Прибыль',
value: summary.pnlTotalRub == null value: summary.pnlTotalRub == null
// null, not zero: something in the portfolio has no price today // null, not zero: something in the portfolio has no price today
? const Text('') ? const Text('')
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'), : MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
), ),
_StatTile( StatTile(
label: 'XIRR, год', label: 'XIRR, год',
value: Text(_percent(yearly?.xirr)), value: Text(_percent(yearly?.xirr)),
), ),
_StatTile( StatTile(
label: 'TWR, год', label: 'TWR, год',
value: Text(_percent(yearly?.twr)), value: Text(_percent(yearly?.twr)),
), ),
], ]),
),
); );
} }