From e4a64cd1dfac603c48d1821d21e2dddea443634d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 15:31:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D1=80=D0=B5=D0=B4=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=20=D0=9E=D0=B1=D0=B7=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8F?= =?UTF-8?q?=D0=B7=D1=8B=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Локальные _StatTile/_ChartCard заменены на общие StatTile/SectionCard, блоки капитала/месяца/инвестиций получили SectionHeader, ряды плиток переведены с Wrap на TileCarousel. Заодно цвет «ок» в чипе качества данных переведён с хардкод Colors.green на ChartColors.slot3Aqua — тот же цвет, что signColor уже использует для положительного знака. --- app/lib/features/home/home_page.dart | 212 +++++++++++---------------- 1 file changed, 83 insertions(+), 129 deletions(-) diff --git a/app/lib/features/home/home_page.dart b/app/lib/features/home/home_page.dart index 9a49aa1..0ca1d58 100644 --- a/app/lib/features/home/home_page.dart +++ b/app/lib/features/home/home_page.dart @@ -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 { 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,59 +126,71 @@ class _HomePageState extends ConsumerState { ), ], ), - 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: 12), - Row( - children: [ - Expanded( - child: AsyncValueView( - value: thisMonth, - onRetry: () => ref.invalidate(cashflowThisMonthProvider), - data: (cached) => _MonthTiles(month: cached.data), - ), - ), - ], - ), + const SizedBox(height: 24), + const SectionHeader(title: 'Этот месяц'), const SizedBox(height: 12), AsyncValueView( - value: portfolio, - onRetry: () => ref.invalidate(portfolioSummaryHomeProvider), - data: (cached) => - cached.data == null ? const SizedBox.shrink() : _PortfolioTiles(summary: cached.data!), + value: thisMonth, + onRetry: () => ref.invalidate(cashflowThisMonthProvider), + data: (cached) => _MonthTiles(month: 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)]), ), - const SizedBox(height: 24), + 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: AsyncValueView( - value: series, - onRetry: () => ref.invalidate(netWorthSeriesProvider), - data: (cached) => cached.data.isEmpty - ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') - : _NetWorthChart(rows: cached.data), + child: SizedBox( + height: 200, + child: AsyncValueView( + value: series, + onRetry: () => ref.invalidate(netWorthSeriesProvider), + data: (cached) => cached.data.isEmpty + ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') + : _NetWorthChart(rows: cached.data), + ), ), ); - final cashflowChart = _ChartCard( + final cashflowChart = SectionCard( title: 'Доход и расход, 12 месяцев', - child: AsyncValueView( - value: last12, - onRetry: () => ref.invalidate(cashflowLast12Provider), - data: (cached) => cached.data.isEmpty - ? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.') - : _IncomeExpenseChart(rows: cached.data), + child: SizedBox( + height: 200, + child: AsyncValueView( + value: last12, + onRetry: () => ref.invalidate(cashflowLast12Provider), + data: (cached) => cached.data.isEmpty + ? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.') + : _IncomeExpenseChart(rows: cached.data), + ), ), ); if (wide) { @@ -209,49 +224,19 @@ class _HomePageState extends ConsumerState { } } -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( - label: 'Портфель', - value: MoneyText(summary.totalRub, currency: 'RUB'), - ), - _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( - label: 'XIRR, год', - value: Text(_percent(yearly?.xirr)), - ), - _StatTile( - label: 'TWR, год', - value: Text(_percent(yearly?.twr)), - ), - ], - ), + child: TileCarousel(children: [ + StatTile( + label: 'Портфель', + value: MoneyText(summary.totalRub, currency: 'RUB'), + ), + 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( + label: 'XIRR, год', + value: Text(_percent(yearly?.xirr)), + ), + StatTile( + label: 'TWR, год', + value: Text(_percent(yearly?.twr)), + ), + ]), ); }