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/stale_banner.dart'; import '../../core/api/api_client.dart'; import 'providers.dart'; double _d(String s) => Decimal.parse(s).toDouble(); Color _severityColor(String severity) { final s = severity.toLowerCase(); if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B); if (s.contains('warn')) return const Color(0xFFFAB219); if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C); return const Color(0xFFEC835A); } /// Обзор: 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 { await ref.read(apiProvider).getMetricsApi().metricsRefresh(); } on DioException catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); } finally { if (mounted) setState(() => _refreshing = false); invalidateHomeProviders(ref); } } void _showDataQuality(List rows) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => SafeArea( child: Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Качество данных', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 12), if (rows.isEmpty) const Text('Проблем не найдено.'), for (final row in rows) Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( margin: const EdgeInsets.only(top: 4, right: 8), width: 10, height: 10, decoration: BoxDecoration(color: _severityColor(row.severity), shape: BoxShape.circle), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${row.checkName} (${row.count})', style: Theme.of(context).textTheme.titleSmall), Text(row.detail), ], ), ), ], ), ), ], ), ), ), ); } @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 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, ]); 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 log = cached.data; 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')}'; return Text(text, style: Theme.of(context).textTheme.bodySmall); }, ), ), 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 ? Colors.green : _severityColor(rows.first.severity), ), label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'), onPressed: rows.isEmpty ? null : () => _showDataQuality(rows), ); }, ), ], ), const SizedBox(height: 16), 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: 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), ), const SizedBox(height: 24), LayoutBuilder( builder: (context, constraints) { final wide = constraints.maxWidth >= 900; final netWorthChart = _ChartCard( 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), ), ); final cashflowChart = _ChartCard( 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), ), ); 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('/sync'), icon: const Icon(Icons.sync, size: 16), label: const Text('Синхронизация'), ), ), ], ), ), ); } } 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')), ], ); } } 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 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)), ], ); } } 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 _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), ], ), ), ); } } 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: 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)), ), ], ), ); } static String _percent(String? value) { if (value == null) return '—'; final pct = _d(value) * 100; final sign = pct > 0 ? '+' : ''; return '$sign${pct.toStringAsFixed(2).replaceAll('.', ',')} %'; } }