/// Overview: current balance, charts, and look-ahead balance forecast. library; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../utils/look_ahead.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; import 'label_transactions_screen.dart'; enum _StatsMode { category, month } class OverviewScreen extends StatefulWidget { const OverviewScreen({super.key}); @override State createState() => _OverviewScreenState(); } class _OverviewScreenState extends State { _StatsMode _mode = _StatsMode.category; var _lookAheadEnabled = false; DateTime _lookAheadMonth = monthStart(DateTime.now()); DateTime _chartMonth = monthStart(DateTime.now()); void _goLookAheadMonth(int delta) { final next = addMonths(_lookAheadMonth, delta); final now = monthStart(DateTime.now()); if (delta < 0 && next.isBefore(addMonths(now, -24))) return; if (delta > 0 && next.isAfter(addMonths(now, 36))) return; setState(() => _lookAheadMonth = next); } void _goChartMonth(int delta) { final next = addMonths(_chartMonth, delta); final now = monthStart(DateTime.now()); if (delta > 0 && next.isAfter(now)) return; if (delta < 0 && next.isBefore(addMonths(now, -36))) return; setState(() => _chartMonth = next.isAfter(now) ? now : next); } void _openLabel(TxLabel label) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => LabelTransactionsScreen(label: label), ), ); } @override Widget build(BuildContext context) { final bank = context.watch(); final meta = context.watch(); return Scaffold( appBar: AppBar( title: const Text('Overview'), actions: bankAppBarActions( context: context, onRefresh: bank.refresh, refreshing: bank.isLoading, ), ), body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( child: _buildBody(context, bank, meta), ), ), ); } Widget _buildBody( BuildContext context, BankController bank, TxMetaController meta, ) { final theme = Theme.of(context); final colors = theme.colorScheme; if (bank.isLoading && bank.transactions.isEmpty && bank.balance == null) { return const LoadingView(message: 'Loading overview…'); } if (!bank.isLinked) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ EmptyState( icon: Icons.insights_outlined, title: 'No overview yet', message: 'Connect your bank on Home to see balance and charts.', ), ], ); } final currency = bank.balance?.currency ?? (bank.transactions.isNotEmpty ? bank.transactions.first.currency : 'EUR'); final currentBalance = bank.balance?.amount ?? 0; final byLabel = meta.spendByLabel(bank.transactions); final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth); final totalSpend = meta.labeledSpendTotal(bank.transactions); final recurrings = recurringItemsFrom( transactions: bank.transactions, metaFor: meta.metaFor, ); final today = dateOnly(DateTime.now()); final lookAheadEnd = DateTime( _lookAheadMonth.year, _lookAheadMonth.month + 1, 0, ); final predicted = predictBalance( currentBalance: currentBalance, from: today, throughDate: lookAheadEnd, items: recurrings, ); final projected = projectedOccurrences( items: recurrings, from: today, to: lookAheadEnd, ); final nowMonth = monthStart(DateTime.now()); return ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ Text('Current balance', style: theme.textTheme.labelMedium), const SizedBox(height: 4), Text( formatMoney(currentBalance, currency), style: theme.textTheme.headlineMedium, ), const SizedBox(height: 20), SwitchListTile( contentPadding: EdgeInsets.zero, secondary: const Icon(Icons.auto_graph_outlined), title: const Text('Look ahead'), subtitle: const Text( 'Project balance from recurring income and spend', ), value: _lookAheadEnabled, onChanged: (value) { setState(() { _lookAheadEnabled = value; if (value && _lookAheadMonth.isBefore(nowMonth)) { _lookAheadMonth = nowMonth; } }); }, ), if (_lookAheadEnabled) ...[ const SizedBox(height: 8), Card( child: Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ IconButton( tooltip: 'Previous month', onPressed: () => _goLookAheadMonth(-1), icon: const Icon(Icons.chevron_left), ), Expanded( child: Text( formatMonthYear(_lookAheadMonth), textAlign: TextAlign.center, style: theme.textTheme.titleMedium, ), ), IconButton( tooltip: 'Next month', onPressed: () => _goLookAheadMonth(1), icon: const Icon(Icons.chevron_right), ), ], ), Text( 'Predicted balance', textAlign: TextAlign.center, style: theme.textTheme.labelMedium, ), const SizedBox(height: 4), Text( formatMoney(predicted, currency), textAlign: TextAlign.center, style: theme.textTheme.headlineMedium?.copyWith( color: predicted >= currentBalance ? colors.primary : colors.error, ), ), const SizedBox(height: 4), Text( _lookAheadMonth == nowMonth && !lookAheadEnd.isAfter(today) ? 'Mark transactions as recurring to forecast' : 'Through ${formatDate(lookAheadEnd)} · ' '${projected.length} projected ' '${projected.length == 1 ? 'item' : 'items'}', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), if (recurrings.isEmpty) ...[ const SizedBox(height: 12), Text( 'No recurring transactions yet. Open a transaction and ' 'enable Recurring.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), ] else if (projected.isNotEmpty) ...[ const SizedBox(height: 12), for (final row in projected.take(12)) ListTile( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 8), leading: Builder( builder: (context) { final label = row.item.labelIds.isEmpty ? null : meta.labelById(row.item.labelIds.first); return Icon( label?.icon ?? Icons.autorenew, color: label?.color, ); }, ), title: Text( row.item.description, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text(formatDate(row.date)), trailing: Text( formatMoney(row.item.amount, row.item.currency), style: theme.textTheme.titleSmall?.copyWith( color: row.item.amount >= 0 ? colors.primary : colors.error, ), ), ), if (projected.length > 12) Text( '+${projected.length - 12} more…', textAlign: TextAlign.center, style: theme.textTheme.labelMedium, ), ], ], ), ), ), ], const SizedBox(height: 20), Text('Money out (labeled)', style: theme.textTheme.labelMedium), const SizedBox(height: 4), Text( formatMoney(totalSpend, currency), style: theme.textTheme.titleLarge, ), const SizedBox(height: 16), SegmentedButton<_StatsMode>( segments: const [ ButtonSegment( value: _StatsMode.category, label: Text('Label'), icon: Icon(Icons.label_outline), ), ButtonSegment( value: _StatsMode.month, label: Text('By day'), icon: Icon(Icons.bar_chart_outlined), ), ], selected: {_mode}, onSelectionChanged: (selection) { setState(() => _mode = selection.first); }, ), if (_mode == _StatsMode.month) ...[ const SizedBox(height: 8), Row( children: [ IconButton( tooltip: 'Previous month', onPressed: () => _goChartMonth(-1), icon: const Icon(Icons.chevron_left), ), Expanded( child: Text( formatMonthYear(_chartMonth), textAlign: TextAlign.center, style: theme.textTheme.titleSmall, ), ), IconButton( tooltip: 'Next month', onPressed: _chartMonth.isBefore(nowMonth) ? () => _goChartMonth(1) : null, icon: const Icon(Icons.chevron_right), ), ], ), ], const SizedBox(height: 12), SizedBox( height: 260, child: bank.transactions.isEmpty ? Center( child: Text( 'Transactions will appear here once loaded.', style: theme.textTheme.bodyMedium, ), ) : _mode == _StatsMode.category ? _LabelBars( data: byLabel, resolve: meta.labelById, currency: currency, onBarTap: _openLabel, ) : _DayBars(data: byDay, currency: currency), ), const SizedBox(height: 20), if (_mode == _StatsMode.category) ..._labelLegend(context, byLabel, meta, currency) else ..._dayLegend(context, byDay, currency), ], ); } List _labelLegend( BuildContext context, Map data, TxMetaController meta, String currency, ) { final entries = data.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); if (entries.isEmpty) { return [ Text( 'No labeled outflows yet. Add a label from a transaction.', style: Theme.of(context).textTheme.bodyMedium, ), ]; } return [ for (final entry in entries) Builder( builder: (context) { final label = meta.labelById(entry.key); if (label == null) return const SizedBox.shrink(); return ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: Icon(label.icon, color: label.color), title: Text(label.name), trailing: Text(formatMoney(entry.value, currency)), onTap: () => _openLabel(label), ); }, ), ]; } List _dayLegend( BuildContext context, Map data, String currency, ) { final spent = data.entries.where((e) => e.value > 0).toList().reversed; if (spent.isEmpty) { return [ Text( 'No outflows this month.', style: Theme.of(context).textTheme.bodyMedium, ), ]; } return [ for (final entry in spent) ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: const Icon(Icons.calendar_today_outlined), title: Text(entry.key), trailing: Text(formatMoney(entry.value, currency)), ), ]; } } class _LabelBars extends StatelessWidget { const _LabelBars({ required this.data, required this.resolve, required this.currency, required this.onBarTap, }); final Map data; final TxLabel? Function(String? id) resolve; final String currency; final void Function(TxLabel label) onBarTap; @override Widget build(BuildContext context) { final entries = <({TxLabel label, double value})>[ for (final entry in data.entries) if (resolve(entry.key) != null) (label: resolve(entry.key)!, value: entry.value), ]..sort((a, b) => b.value.compareTo(a.value)); if (entries.isEmpty) { return Center( child: Text( 'Label transactions to see this chart', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), ); } final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b); return BarChart( BarChartData( maxY: maxY * 1.15, barTouchData: BarTouchData( touchCallback: (event, response) { if (!event.isInterestedForInteractions) return; final index = response?.spot?.touchedBarGroupIndex; if (index == null || index < 0 || index >= entries.length) return; if (event is FlTapUpEvent) { onBarTap(entries[index].label); } }, touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final entry = entries[group.x.toInt()]; return BarTooltipItem( '${entry.label.name}\n${formatMoney(entry.value, currency)}', TextStyle( color: Theme.of(context).colorScheme.onInverseSurface, ), ); }, ), ), 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, reservedSize: 42, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index < 0 || index >= entries.length) { return const SizedBox.shrink(); } final label = entries[index].label.name; return Padding( padding: const EdgeInsets.only(top: 8), child: Text( label.length > 8 ? '${label.substring(0, 7)}…' : label, style: Theme.of(context).textTheme.labelSmall, ), ); }, ), ), ), gridData: const FlGridData(show: false), borderData: FlBorderData(show: false), barGroups: [ for (var i = 0; i < entries.length; i++) BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: entries[i].value, width: 18, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), color: entries[i].label.color, ), ], ), ], ), ); } } class _DayBars extends StatelessWidget { const _DayBars({required this.data, required this.currency}); final Map data; final String currency; @override Widget build(BuildContext context) { final entries = data.entries.toList(); if (entries.every((e) => e.value == 0)) { return Center( child: Text( 'No spend this month', style: Theme.of(context).textTheme.bodyMedium, ), ); } final maxY = entries.map((e) => e.value).fold(0, (a, b) => a > b ? a : b); final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; final colors = Theme.of(context).colorScheme; return BarChart( BarChartData( maxY: safeMax, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final entry = entries[group.x.toInt()]; final day = entry.key.length >= 10 ? entry.key.substring(8) : entry.key; return BarTooltipItem( 'Day $day\n${formatMoney(entry.value, currency)}', TextStyle(color: colors.onInverseSurface), ); }, ), ), 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, reservedSize: 28, interval: entries.length > 16 ? 5 : 2, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index < 0 || index >= entries.length) { return const SizedBox.shrink(); } if (entries.length > 16 && index % 5 != 0) { return const SizedBox.shrink(); } final day = (index + 1).toString(); return Padding( padding: const EdgeInsets.only(top: 8), child: Text( day, style: Theme.of(context).textTheme.labelSmall, ), ); }, ), ), ), gridData: const FlGridData(show: false), borderData: FlBorderData(show: false), barGroups: [ for (var i = 0; i < entries.length; i++) BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: entries[i].value, width: entries.length > 20 ? 6 : 8, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), color: entries[i].value > 0 ? colors.primary : colors.surfaceContainerHighest, ), ], ), ], ), ); } }