/// 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'; 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()); void _goLookAheadMonth(int delta) { final next = addMonths(_lookAheadMonth, delta); final now = monthStart(DateTime.now()); // Look ahead can go forward; still allow browsing past months. if (delta < 0 && next.isBefore(addMonths(now, -24))) return; if (delta > 0 && next.isAfter(addMonths(now, 36))) return; setState(() => _lookAheadMonth = next); } @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 byCategory = meta.spendByCategory(bank.transactions); final byMonth = meta.spendByMonth(bank.transactions); final totalSpend = byCategory.values.fold(0, (sum, v) => sum + v); 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: Icon( row.item.category.icon, color: row.item.category.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 (categorized)', 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('Category'), icon: Icon(Icons.label_outline), ), ButtonSegment( value: _StatsMode.month, label: Text('By date'), icon: Icon(Icons.calendar_month_outlined), ), ], selected: {_mode}, onSelectionChanged: (selection) { setState(() => _mode = selection.first); }, ), const SizedBox(height: 20), SizedBox( height: 260, child: bank.transactions.isEmpty ? Center( child: Text( 'Transactions will appear here once loaded.', style: theme.textTheme.bodyMedium, ), ) : _mode == _StatsMode.category ? _CategoryBars(data: byCategory, currency: currency) : _MonthBars(data: byMonth, currency: currency), ), const SizedBox(height: 20), if (_mode == _StatsMode.category) ..._categoryLegend(context, byCategory, currency) else ..._monthLegend(context, byMonth, currency), ], ); } List _categoryLegend( BuildContext context, Map data, String currency, ) { final entries = data.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); if (entries.isEmpty) { return [ Text( 'No outflows yet. Categorize spending from the transaction list.', style: Theme.of(context).textTheme.bodyMedium, ), ]; } return [ for (final entry in entries) ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: Icon(entry.key.icon, color: entry.key.color), title: Text(entry.key.label), trailing: Text(formatMoney(entry.value, currency)), ), ]; } List _monthLegend( BuildContext context, Map data, String currency, ) { if (data.isEmpty) { return [ Text( 'No dated outflows to chart yet.', style: Theme.of(context).textTheme.bodyMedium, ), ]; } return [ for (final entry in data.entries.toList().reversed) ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: const Icon(Icons.calendar_today_outlined), title: Text(entry.key), trailing: Text(formatMoney(entry.value, currency)), ), ]; } } class _CategoryBars extends StatelessWidget { const _CategoryBars({required this.data, required this.currency}); final Map data; final String currency; @override Widget build(BuildContext context) { final entries = data.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); if (entries.isEmpty) { return Center( child: Text( 'Categorize 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( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final entry = entries[group.x.toInt()]; return BarTooltipItem( '${entry.key.label}\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].key.label; 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].key.color, ), ], ), ], ), ); } } class _MonthBars extends StatelessWidget { const _MonthBars({required this.data, required this.currency}); final Map data; final String currency; @override Widget build(BuildContext context) { final entries = data.entries.toList(); if (entries.isEmpty) { return Center( child: Text( 'No monthly spend yet', style: Theme.of(context).textTheme.bodyMedium, ), ); } final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b); final colors = Theme.of(context).colorScheme; return BarChart( BarChartData( maxY: maxY * 1.15, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final entry = entries[group.x.toInt()]; return BarTooltipItem( '${entry.key}\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, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index < 0 || index >= entries.length) { return const SizedBox.shrink(); } final key = entries[index].key; final short = key.length >= 7 ? key.substring(5) : key; return Padding( padding: const EdgeInsets.only(top: 8), child: Text( short, 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: 22, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), color: colors.primary, ), ], ), ], ), ); } }