/// Overview: monthly income/expense chart, summary cards, and day history. library; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../providers/bank_provider.dart'; import '../utils/helpers.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 'transactions_screen.dart'; class OverviewScreen extends StatefulWidget { const OverviewScreen({super.key}); @override State createState() => _OverviewScreenState(); } class _OverviewScreenState extends State { static const _chartMonths = 6; @override Widget build(BuildContext context) { final bank = 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), ), ), ); } Widget _buildBody(BuildContext context, BankController bank) { 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 && !bank.hasData) { 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 months = _lastMonths(_chartMonths); final monthly = _monthlyTotals(bank.transactions, months); final nowMonth = monthStart(DateTime.now()); final current = monthly[nowMonth] ?? const _MonthBucket(income: 0, expense: 0); final history = _monthHistory(bank.transactions); final incomeColor = colors.primary; final expenseColor = const Color(0xFF6FCF97); return ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(0, 8, 0, 32), children: [ if (bank.error != null) ...[ Text( bank.error!, style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), ), const SizedBox(height: 12), ], _ChartCard( months: months, monthly: monthly, currency: currency, incomeColor: incomeColor, expenseColor: expenseColor, ), const SizedBox(height: 14), Row( children: [ Expanded( child: _SummaryCard( icon: Icons.savings_outlined, iconBackground: incomeColor.withValues(alpha: 0.14), iconColor: incomeColor, amount: formatMoney(current.income, currency), label: 'Income', ), ), const SizedBox(width: 12), Expanded( child: _SummaryCard( icon: Icons.receipt_long_outlined, iconBackground: expenseColor.withValues(alpha: 0.18), iconColor: const Color(0xFF2F9E62), amount: formatMoney(current.expense, currency), label: 'Expenses', ), ), ], ), const SizedBox(height: 8), Text( formatMonthYear(nowMonth), style: theme.textTheme.labelMedium, ), const SizedBox(height: 20), Text('History', style: theme.textTheme.titleLarge), const SizedBox(height: 12), if (history.isEmpty) Text( 'No monthly activity to show.', style: theme.textTheme.bodyMedium, ) else for (final month in history) ...[ _HistoryCard( month: month.month, income: month.income, expense: month.expense, currency: currency, onTap: () => _openMonth(context, month.month), ), const SizedBox(height: 10), ], ], ); } void _openMonth(BuildContext context, DateTime month) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => TransactionsScreen(initialMonth: month), ), ); } } class _MonthBucket { const _MonthBucket({required this.income, required this.expense}); final double income; final double expense; } class _MonthHistoryRow { const _MonthHistoryRow({ required this.month, required this.income, required this.expense, }); final DateTime month; final double income; final double expense; } List _lastMonths(int count) { final now = monthStart(DateTime.now()); return [ for (var i = count - 1; i >= 0; i--) addMonths(now, -i), ]; } DateTime? _txDate(BankTransaction tx) { final parsed = DateTime.tryParse(tx.date); return parsed == null ? null : dateOnly(parsed); } Map _monthlyTotals( List txs, List months, ) { final totals = { for (final month in months) month: (income: 0.0, expense: 0.0), }; for (final tx in txs) { final date = _txDate(tx); if (date == null) continue; final key = monthStart(date); final current = totals[key]; if (current == null) continue; if (tx.amount >= 0) { totals[key] = (income: current.income + tx.amount, expense: current.expense); } else { totals[key] = ( income: current.income, expense: current.expense + tx.amount.abs(), ); } } return { for (final e in totals.entries) e.key: _MonthBucket(income: e.value.income, expense: e.value.expense), }; } /// Months that have activity, newest first. List<_MonthHistoryRow> _monthHistory(List txs) { final map = {}; for (final tx in txs) { final date = _txDate(tx); if (date == null) continue; final key = monthStart(date); final current = map[key] ?? (income: 0.0, expense: 0.0); if (tx.amount >= 0) { map[key] = (income: current.income + tx.amount, expense: current.expense); } else { map[key] = ( income: current.income, expense: current.expense + tx.amount.abs(), ); } } final keys = map.keys.toList()..sort((a, b) => b.compareTo(a)); return [ for (final key in keys) _MonthHistoryRow( month: key, income: map[key]!.income, expense: map[key]!.expense, ), ]; } class _ChartCard extends StatelessWidget { const _ChartCard({ required this.months, required this.monthly, required this.currency, required this.incomeColor, required this.expenseColor, }); final List months; final Map monthly; final String currency; final Color incomeColor; final Color expenseColor; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; var maxY = 0.0; for (final month in months) { final bucket = monthly[month]!; if (bucket.income > maxY) maxY = bucket.income; if (bucket.expense > maxY) maxY = bucket.expense; } final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; return Card( color: colors.surface, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), child: Column( children: [ Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: colors.surfaceContainerHighest.withValues(alpha: 0.55), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('Monthly', style: theme.textTheme.labelLarge), const SizedBox(width: 4), Icon( Icons.keyboard_arrow_down, size: 18, color: colors.onSurfaceVariant, ), ], ), ), const Spacer(), _LegendDot(color: incomeColor, label: 'Income'), const SizedBox(width: 14), _LegendDot(color: expenseColor, label: 'Expense'), ], ), const SizedBox(height: 16), SizedBox( height: 220, child: months.every( (m) => monthly[m]!.income == 0 && monthly[m]!.expense == 0, ) ? Center( child: Text( 'No income or expenses in this period', style: theme.textTheme.bodyMedium, textAlign: TextAlign.center, ), ) : BarChart( BarChartData( maxY: safeMax, groupsSpace: 18, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final month = months[group.x.toInt()]; final bucket = monthly[month]!; final isIncome = rodIndex == 1; final value = isIncome ? bucket.income : bucket.expense; return BarTooltipItem( '${_shortMonth(month)}\n' '${isIncome ? 'Income' : 'Expense'}\n' '${formatMoney(value, currency)}', TextStyle(color: colors.onInverseSurface), ); }, ), ), titlesData: FlTitlesData( topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 42, interval: safeMax / 3, getTitlesWidget: (value, meta) { if (value <= 0) { return Text( '0', style: theme.textTheme.labelSmall, ); } if ((value - safeMax).abs() < 0.01) { return const SizedBox.shrink(); } return Text( _compactMoney(value), style: theme.textTheme.labelSmall, ); }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index < 0 || index >= months.length) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 8), child: Text( _shortMonth(months[index]), style: theme.textTheme.labelSmall, ), ); }, ), ), ), gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: safeMax / 3, getDrawingHorizontalLine: (value) => FlLine( color: colors.outlineVariant.withValues(alpha: 0.45), strokeWidth: 1, ), ), borderData: FlBorderData(show: false), barGroups: [ for (var i = 0; i < months.length; i++) BarChartGroupData( x: i, barsSpace: 4, barRods: [ BarChartRodData( toY: monthly[months[i]]!.expense, width: 12, borderRadius: const BorderRadius.vertical( top: Radius.circular(8), ), color: expenseColor, ), BarChartRodData( toY: monthly[months[i]]!.income, width: 12, borderRadius: const BorderRadius.vertical( top: Radius.circular(8), ), color: incomeColor, ), ], ), ], ), ), ), ], ), ), ); } static String _shortMonth(DateTime month) { const names = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; return names[month.month - 1]; } static String _compactMoney(double value) { if (value >= 1000) { final k = value / 1000; return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'; } return value.round().toString(); } } 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: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 6), Text(label, style: Theme.of(context).textTheme.labelMedium), ], ); } } class _SummaryCard extends StatelessWidget { const _SummaryCard({ required this.icon, required this.iconBackground, required this.iconColor, required this.amount, required this.label, }); final IconData icon; final Color iconBackground; final Color iconColor; final String amount; final String label; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; return Card( color: colors.surface, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 14, 12, 14), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: iconBackground, borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: iconColor, size: 22), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( amount, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), Text(label, style: theme.textTheme.labelMedium), ], ), ), ], ), ), ); } } class _HistoryCard extends StatelessWidget { const _HistoryCard({ required this.month, required this.income, required this.expense, required this.currency, required this.onTap, }); final DateTime month; final double income; final double expense; final String currency; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; return Card( color: colors.surface, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), ), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: onTap, child: Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Month', style: theme.textTheme.labelMedium), const SizedBox(height: 2), Text( formatMonthYear(month), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( formatMoney(income, currency), style: theme.textTheme.titleSmall?.copyWith( color: const Color(0xFF2F9E62), fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( formatMoney(-expense, currency), style: theme.textTheme.titleSmall?.copyWith( color: colors.error, fontWeight: FontWeight.w600, ), ), ], ), ], ), ), ), ); } }