feat(app): редизайн Обзора под новый визуальный язык
Локальные _StatTile/_ChartCard заменены на общие StatTile/SectionCard, блоки капитала/месяца/инвестиций получили SectionHeader, ряды плиток переведены с Wrap на TileCarousel. Заодно цвет «ок» в чипе качества данных переведён с хардкод Colors.green на ChartColors.slot3Aqua — тот же цвет, что signColor уже использует для положительного знака.
This commit is contained in:
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user