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/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 'providers.dart'; const _incomeColor = ChartColors.income; const _expenseColor = ChartColors.expense; double _d(String s) => Decimal.parse(s).toDouble(); /// Потоки: 24 months of cashflow, a grouped bar chart on top and the full /// breakdown as a scrollable table below. Tapping a row opens Категории for /// that month. class CashflowPage extends ConsumerWidget { const CashflowPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final monthly = ref.watch(cashflowMonthly24Provider); final stale = oldestFetch([monthly.valueOrNull?.fetchedAt]); return Scaffold( appBar: AppBar(title: const Text('Потоки')), body: RefreshIndicator( onRefresh: () async => ref.invalidate(cashflowMonthly24Provider), child: AsyncValueView( value: monthly, onRetry: () => ref.invalidate(cashflowMonthly24Provider), data: (cached) { final rows = cached.data; if (rows.isEmpty) { return ListView( children: const [ EmptyState( icon: Icons.swap_horiz_outlined, message: 'Данных о потоках ещё нет — нужна синхронизация.', ), ], ); } return ListView( padding: const EdgeInsets.all(16), children: [ if (stale != null) StaleBanner(fetchedAt: stale), const _Legend(), const SizedBox(height: 8), SizedBox( height: 240, child: SingleChildScrollView( scrollDirection: Axis.horizontal, reverse: true, child: SizedBox( width: (rows.length * 56).toDouble().clamp(320, double.infinity), child: _CashflowChart(rows: rows), ), ), ), const SizedBox(height: 24), _Table(rows: rows), ], ); }, ), ), ); } } class _Legend extends StatelessWidget { const _Legend(); @override Widget build(BuildContext context) { return Row( children: const [ _LegendDot(color: _incomeColor, label: 'Доход'), SizedBox(width: 16), _LegendDot(color: _expenseColor, label: 'Расход'), ], ); } } 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), ], ); } } class _CashflowChart extends StatelessWidget { const _CashflowChart({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 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: 10)), ); }, ), ), ), 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: _incomeColor, width: 8), BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8), ], barsSpace: 2, ), ], ), ); } } class _Table extends StatelessWidget { const _Table({required this.rows}); final List rows; @override Widget build(BuildContext context) { final headerStyle = Theme.of(context).textTheme.labelMedium; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( columns: [ DataColumn(label: Text('Месяц', style: headerStyle)), DataColumn(label: Text('Доход', style: headerStyle), numeric: true), DataColumn(label: Text('Расход', style: headerStyle), numeric: true), DataColumn(label: Text('Базовые', style: headerStyle), numeric: true), DataColumn(label: Text('Разовые', style: headerStyle), numeric: true), DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true), DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true), ], rows: [ for (final r in rows.reversed) DataRow( onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'), cells: [ DataCell(Text(ruMonthYearShort(r.month))), DataCell(MoneyText(r.incomeRub, currency: 'RUB')), DataCell(MoneyText(r.expenseRub, currency: 'RUB')), DataCell(MoneyText(r.baselineRub, currency: 'RUB')), DataCell(MoneyText(r.oneOffRub, currency: 'RUB')), DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')), DataCell(Text(r.savingsRate == null ? '—' : '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')), ], ), ], ), ); } }