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/empty_state.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/tile_carousel.dart';
import '../../core/api/api_client.dart';
import '../health/data_quality_list.dart' show severityColor;
import 'providers.dart';
@@ -112,7 +115,7 @@ class _HomePageState extends ConsumerState<HomePage> {
avatar: Icon(
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
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} замечаний'),
// 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(
value: breakdown,
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
data: (cached) => _NetWorthTiles(breakdown: cached.data),
),
const SizedBox(height: 24),
const SectionHeader(title: 'Этот месяц'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: AsyncValueView(
AsyncValueView(
value: thisMonth,
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
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),
AsyncValueView(
value: runway,
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 SectionHeader(title: 'Инвестиции'),
const SizedBox(height: 12),
_PortfolioTiles(summary: cached.data!),
],
),
),
const SizedBox(height: 28),
LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 900;
final netWorthChart = _ChartCard(
final netWorthChart = SectionCard(
title: 'Капитал за 365 дней',
child: SizedBox(
height: 200,
child: AsyncValueView(
value: series,
onRetry: () => ref.invalidate(netWorthSeriesProvider),
@@ -167,9 +178,12 @@ class _HomePageState extends ConsumerState<HomePage> {
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
: _NetWorthChart(rows: cached.data),
),
),
);
final cashflowChart = _ChartCard(
final cashflowChart = SectionCard(
title: 'Доход и расход, 12 месяцев',
child: SizedBox(
height: 200,
child: AsyncValueView(
value: last12,
onRetry: () => ref.invalidate(cashflowLast12Provider),
@@ -177,6 +191,7 @@ class _HomePageState extends ConsumerState<HomePage> {
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
: _IncomeExpenseChart(rows: cached.data),
),
),
);
if (wide) {
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 {
const _NetWorthTiles({required this.breakdown});
final NetWorthBreakdown breakdown;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
_StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
_StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, 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')),
],
);
return TileCarousel(children: [
StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, 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: 'Данных за этот месяц нет.');
}
final rateText = month!.savingsRate == null ? '' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
_StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
_StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
_StatTile(label: 'Норма сбережений', value: Text(rateText)),
],
);
return TileCarousel(children: [
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
? ''
: '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.';
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),
],
),
),
);
return StatTile(label: 'Запас хода (runway)', value: Text(text));
}
}
@@ -464,31 +422,27 @@ class _PortfolioTiles extends StatelessWidget {
return InkWell(
onTap: () => context.go('/portfolio'),
borderRadius: BorderRadius.circular(12),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
_StatTile(
child: TileCarousel(children: [
StatTile(
label: 'Портфель',
value: MoneyText(summary.totalRub, currency: 'RUB'),
),
_StatTile(
StatTile(
label: 'Прибыль',
value: summary.pnlTotalRub == null
// null, not zero: something in the portfolio has no price today
? const Text('')
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
),
_StatTile(
StatTile(
label: 'XIRR, год',
value: Text(_percent(yearly?.xirr)),
),
_StatTile(
StatTile(
label: 'TWR, год',
value: Text(_percent(yearly?.twr)),
),
],
),
]),
);
}