import 'package:dio/dio.dart'; import 'package:decimal/decimal.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_controller.dart'; import '../../core/cache/cached.dart'; import '../../core/theme/chart_colors.dart'; 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'; import 'scope_cards.dart'; double _d(String s) => Decimal.parse(s).toDouble(); /// The refresh log row can be a run still in progress, which has no failed step yet. String _failedStepNote(String? step) => step == null ? '' : ' (шаг «$step»)'; /// Обзор: the dashboard landing page — net worth, this month's cashflow, /// runway, a net worth line chart, a 12-month income/expense bar chart, and /// a data-quality summary linking to the findings. class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override ConsumerState createState() => _HomePageState(); } class _HomePageState extends ConsumerState { bool _refreshing = false; Future _refresh() async { setState(() => _refreshing = true); try { final metrics = ref.read(apiProvider).getMetricsApi(); await metrics .metricsRefresh(); // 202: only queued, the worker does the rebuild final done = await waitForMetricsRefresh( () async => (await metrics.metricsStatus()).data!, ); if (mounted) { final message = done == null ? 'Пересчёт идёт дольше обычного — данные обновятся, когда он закончится' : done.consistent ? null : 'Пересчёт не завершён${_failedStepNote(done.lastRefresh?.failedStep)} — ' 'часть метрик может не сходиться'; if (message != null) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(message))); } } } on DioException catch (e) { if (!mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(problemMessage(e)))); } finally { if (mounted) setState(() => _refreshing = false); invalidateHomeProviders(ref); } } @override Widget build(BuildContext context) { final breakdown = ref.watch(netWorthBreakdownProvider); final series = ref.watch(netWorthSeriesProvider); final thisMonth = ref.watch(cashflowThisMonthProvider); final last12 = ref.watch(cashflowLast12Provider); final runway = ref.watch(runwayProvider); final status = ref.watch(metricsStatusProvider); final dataQuality = ref.watch(dataQualityProvider); final portfolio = ref.watch(portfolioSummaryHomeProvider); final cards = ref.watch(scopeCardsProvider); final stale = oldestFetch([ breakdown.valueOrNull?.fetchedAt, series.valueOrNull?.fetchedAt, thisMonth.valueOrNull?.fetchedAt, last12.valueOrNull?.fetchedAt, runway.valueOrNull?.fetchedAt, status.valueOrNull?.fetchedAt, dataQuality.valueOrNull?.fetchedAt, portfolio.valueOrNull?.fetchedAt, cards.valueOrNull?.fetchedAt, ]); return Scaffold( appBar: AppBar( title: const Text('Обзор'), actions: [ IconButton( tooltip: 'Пересчитать метрики', icon: _refreshing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), onPressed: _refreshing ? null : _refresh, ), ], ), body: RefreshIndicator( onRefresh: () async => invalidateHomeProviders(ref), child: ListView( padding: const EdgeInsets.all(16), children: [ if (stale != null) StaleBanner(fetchedAt: stale), Row( children: [ Expanded( child: AsyncValueView( value: status, onRetry: () => ref.invalidate(metricsStatusProvider), data: (cached) { final status = cached.data; final log = status.lastRefresh; final at = log?.finishedAt ?? log?.startedAt; final text = at == null ? 'Данные ещё не пересчитывались' : 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}'; final style = Theme.of(context).textTheme.bodySmall; if (status.consistent) return Text(text, style: style); return Text( '$text · пересчёт не завершён${_failedStepNote(log?.failedStep)}, ' 'часть метрик может не сходиться', style: style?.copyWith( color: Theme.of(context).colorScheme.error, ), ); }, ), ), AsyncValueView( value: dataQuality, data: (cached) { final rows = cached.data; return ActionChip( avatar: Icon( rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined, size: 18, color: rows.isEmpty ? ChartColors.slot3Aqua : severityColor(rows.first.severity), ), label: Text( rows.isEmpty ? 'ок' : '${rows.length} замечаний', ), // Health page renders the same findings via DataQualityList — no // second implementation of this list here. onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'), ); }, ), ], ), AsyncValueView( value: cards, onRetry: () => ref.invalidate(scopeCardsProvider), data: (cached) => cached.data.isEmpty ? const SizedBox.shrink() : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 20), const SectionHeader(title: 'Портфели'), const SizedBox(height: 12), ScopeCards(cards: cached.data), ], ), ), const SizedBox(height: 24), 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), AsyncValueView( 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) => 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 = SectionCard( title: 'Капитал за 365 дней', 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 = SectionCard( title: 'Доход и расход, 12 месяцев', 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) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: netWorthChart), const SizedBox(width: 16), Expanded(child: cashflowChart), ], ); } return Column( children: [ netWorthChart, const SizedBox(height: 16), cashflowChart, ], ); }, ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: () => context.go('/health'), icon: const Icon(Icons.monitor_heart_outlined, size: 16), label: const Text('Здоровье'), ), ), ], ), ), ); } } class _NetWorthTiles extends StatelessWidget { const _NetWorthTiles({required this.breakdown}); final NetWorthBreakdown breakdown; @override Widget build(BuildContext context) { 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'), ), ], ); } } class _MonthTiles extends StatelessWidget { const _MonthTiles({required this.month}); final CashFlowMonth? month; @override Widget build(BuildContext context) { if (month == null) { return const EmptyState( icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.', ); } final rateText = month!.savingsRate == null ? '—' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%'; return TileCarousel( children: [ StatTile( label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB'), ), StatTile( label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB'), ), StatTile(label: 'Норма сбережений', value: Text(rateText)), ], ); } } class _RunwayTile extends StatelessWidget { const _RunwayTile({required this.runway}); final RunwayOut runway; @override Widget build(BuildContext context) { final text = runway.runwayMonths == null ? '—' : '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.'; return StatTile(label: 'Запас хода (runway)', value: Text(text)); } } class _NetWorthChart extends StatelessWidget { const _NetWorthChart({required this.rows}); final List rows; @override Widget build(BuildContext context) { final spots = [ for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)), ]; return LineChart( LineChartData( gridData: const FlGridData(drawVerticalLine: false), borderData: FlBorderData(show: false), titlesData: const FlTitlesData( topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (spots) => [ for (final s in spots) LineTooltipItem( MoneyText.format(s.y.toStringAsFixed(2), 'RUB'), const TextStyle(color: Colors.white, fontSize: 12), ), ], ), ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: false, barWidth: 2, color: ChartColors.slot1Blue, dotData: const FlDotData(show: false), ), ], ), ); } } class _IncomeExpenseChart extends StatelessWidget { const _IncomeExpenseChart({required this.rows}); final List rows; @override Widget build(BuildContext context) { final maxY = rows.fold( 0, (m, r) => [ m, _d(r.incomeRub), _d(r.expenseRub), ].reduce((a, b) => a > b ? a : b), ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: const [ _LegendDot(color: ChartColors.income, label: 'Доход'), SizedBox(width: 16), _LegendDot(color: ChartColors.expense, label: 'Расход'), ], ), const SizedBox(height: 8), Expanded( child: BarChart( BarChartData( maxY: maxY * 1.1, gridData: const FlGridData(drawVerticalLine: false), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { final i = value.toInt(); if (i < 0 || i >= rows.length) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 6), child: Text( ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9), ), ); }, ), ), ), barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final label = rodIndex == 0 ? 'Доход' : 'Расход'; return BarTooltipItem( '$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}', const TextStyle(color: Colors.white, fontSize: 12), ); }, ), ), barGroups: [ for (var i = 0; i < rows.length; i++) BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6, ), BarChartRodData( toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6, ), ], barsSpace: 2, ), ], ), ), ), ], ); } } class _LegendDot extends StatelessWidget { const _LegendDot({required this.color, required this.label}); final Color color; final String label; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 6), Text(label, style: Theme.of(context).textTheme.bodySmall), ], ); } } /// The investment row of the dashboard: value, profit and the two returns, each linking /// through to Портфель. Absent entirely until a broker ledger exists. class _PortfolioTiles extends StatelessWidget { const _PortfolioTiles({required this.summary}); final SummaryOut summary; @override Widget build(BuildContext context) { final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull; return InkWell( onTap: () => context.go('/portfolio'), borderRadius: BorderRadius.circular(12), 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))), ], ), ); } static String _percent(String? value) { if (value == null) return '—'; final pct = _d(value) * 100; final sign = pct > 0 ? '+' : ''; return '$sign${pct.toStringAsFixed(2).replaceAll('.', ',')} %'; } }