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/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,59 +126,71 @@ 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: 12),
|
const SizedBox(height: 24),
|
||||||
Row(
|
const SectionHeader(title: 'Этот месяц'),
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: AsyncValueView(
|
|
||||||
value: thisMonth,
|
|
||||||
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
|
|
||||||
data: (cached) => _MonthTiles(month: cached.data),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
AsyncValueView(
|
AsyncValueView(
|
||||||
value: portfolio,
|
value: thisMonth,
|
||||||
onRetry: () => ref.invalidate(portfolioSummaryHomeProvider),
|
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
|
||||||
data: (cached) =>
|
data: (cached) => _MonthTiles(month: cached.data),
|
||||||
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)]),
|
||||||
),
|
),
|
||||||
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(
|
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: AsyncValueView(
|
child: SizedBox(
|
||||||
value: series,
|
height: 200,
|
||||||
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
child: AsyncValueView(
|
||||||
data: (cached) => cached.data.isEmpty
|
value: series,
|
||||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
||||||
: _NetWorthChart(rows: cached.data),
|
data: (cached) => cached.data.isEmpty
|
||||||
|
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||||
|
: _NetWorthChart(rows: cached.data),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final cashflowChart = _ChartCard(
|
final cashflowChart = SectionCard(
|
||||||
title: 'Доход и расход, 12 месяцев',
|
title: 'Доход и расход, 12 месяцев',
|
||||||
child: AsyncValueView(
|
child: SizedBox(
|
||||||
value: last12,
|
height: 200,
|
||||||
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
child: AsyncValueView(
|
||||||
data: (cached) => cached.data.isEmpty
|
value: last12,
|
||||||
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
||||||
: _IncomeExpenseChart(rows: cached.data),
|
data: (cached) => cached.data.isEmpty
|
||||||
|
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
||||||
|
: _IncomeExpenseChart(rows: cached.data),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (wide) {
|
if (wide) {
|
||||||
@@ -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,
|
label: 'Портфель',
|
||||||
children: [
|
value: MoneyText(summary.totalRub, currency: 'RUB'),
|
||||||
_StatTile(
|
),
|
||||||
label: 'Портфель',
|
StatTile(
|
||||||
value: MoneyText(summary.totalRub, currency: 'RUB'),
|
label: 'Прибыль',
|
||||||
),
|
value: summary.pnlTotalRub == null
|
||||||
_StatTile(
|
// null, not zero: something in the portfolio has no price today
|
||||||
label: 'Прибыль',
|
? const Text('—')
|
||||||
value: summary.pnlTotalRub == null
|
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
|
||||||
// null, not zero: something in the portfolio has no price today
|
),
|
||||||
? const Text('—')
|
StatTile(
|
||||||
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
|
label: 'XIRR, год',
|
||||||
),
|
value: Text(_percent(yearly?.xirr)),
|
||||||
_StatTile(
|
),
|
||||||
label: 'XIRR, год',
|
StatTile(
|
||||||
value: Text(_percent(yearly?.xirr)),
|
label: 'TWR, год',
|
||||||
),
|
value: Text(_percent(yearly?.twr)),
|
||||||
_StatTile(
|
),
|
||||||
label: 'TWR, год',
|
]),
|
||||||
value: Text(_percent(yearly?.twr)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user