diff --git a/lib/screens/look_ahead_screen.dart b/lib/screens/look_ahead_screen.dart index 2e8b52f..f08278e 100644 --- a/lib/screens/look_ahead_screen.dart +++ b/lib/screens/look_ahead_screen.dart @@ -3,6 +3,7 @@ library; import 'dart:async'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -33,6 +34,18 @@ class _ItemsSnapshot { final List items; } +class _BalancePoint { + const _BalancePoint({ + required this.key, + required this.label, + required this.balance, + }); + + final DateTime key; + final String label; + final double balance; +} + class LookAheadScreen extends StatefulWidget { const LookAheadScreen({super.key}); @@ -48,6 +61,7 @@ class _LookAheadScreenState extends State { _ItemsSnapshot? _snapshot; int? _loadingKey; int _computeGen = 0; + var _showChart = false; @override void initState() { @@ -63,14 +77,15 @@ class _LookAheadScreenState extends State { /// Inclusive end date for the selected period. DateTime get _through { - if (_period == _PeriodMode.yearly) { + // Chart view (and yearly list) always project through year-end. + if (_showChart || _period == _PeriodMode.yearly) { return DateTime(_cursor.year, 12, 31); } return DateTime(_cursor.year, _cursor.month + 1, 0); } String get _periodTitle { - if (_period == _PeriodMode.yearly) return '${_cursor.year}'; + if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}'; return formatMonthYear(_cursor); } @@ -79,28 +94,32 @@ class _LookAheadScreenState extends State { return DateTime(t.year + maxYears, t.month); } + int _maxYear(int maxYears) => _today.year + maxYears; + + bool get _navByYear => _showChart || _period == _PeriodMode.yearly; + bool _canGoBack() { - if (_period == _PeriodMode.yearly) { - return _cursor.year > _today.year; - } + if (_navByYear) return _cursor.year > _today.year; return monthStart(_cursor).isAfter(_minMonth); } bool _canGoForward(int maxYears) { - if (_period == _PeriodMode.yearly) { - return _cursor.year < _today.year + maxYears; - } + if (_navByYear) return _cursor.year < _maxYear(maxYears); return monthStart(_cursor).isBefore(_maxMonthFor(maxYears)); } void _clampCursor(int maxYears) { - if (_period == _PeriodMode.yearly) { - if (_cursor.year > _today.year + maxYears) { - _cursor = DateTime(_today.year + maxYears); + if (_navByYear) { + if (_cursor.year > _maxYear(maxYears)) { + _cursor = DateTime(_maxYear(maxYears)); + } + if (_cursor.year < _today.year) { + _cursor = DateTime(_today.year); } } else { final maxMonth = _maxMonthFor(maxYears); if (_cursor.isAfter(maxMonth)) _cursor = maxMonth; + if (_cursor.isBefore(_minMonth)) _cursor = _minMonth; } } @@ -108,26 +127,22 @@ class _LookAheadScreenState extends State { if (mode == _period) return; setState(() { _period = mode; - if (mode == _PeriodMode.yearly) { + if (mode == _PeriodMode.yearly || _showChart) { _cursor = DateTime(_cursor.year); - if (_cursor.year < _today.year) _cursor = DateTime(_today.year); - if (_cursor.year > _today.year + maxYears) { - _cursor = DateTime(_today.year + maxYears); - } + _clampCursor(maxYears); } else { final month = DateTime(_cursor.year, _today.month); _cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month); - final maxMonth = _maxMonthFor(maxYears); - if (_cursor.isAfter(maxMonth)) _cursor = maxMonth; + _clampCursor(maxYears); } }); } void _go(int delta, int maxYears) { setState(() { - if (_period == _PeriodMode.yearly) { + if (_navByYear) { final next = DateTime(_cursor.year + delta); - if (next.year < _today.year || next.year > _today.year + maxYears) { + if (next.year < _today.year || next.year > _maxYear(maxYears)) { return; } _cursor = next; @@ -140,9 +155,80 @@ class _LookAheadScreenState extends State { }); } + void _toggleChart(int maxYears) { + setState(() { + _showChart = !_showChart; + if (_showChart) { + _cursor = DateTime(_cursor.year); + } else if (_period == _PeriodMode.monthly) { + final month = DateTime(_cursor.year, _today.month); + _cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month); + } + _clampCursor(maxYears); + }); + } + int _itemsCacheKey(TxMetaController meta, BankController bank) => Object.hash(meta.storeRevision, identityHashCode(bank.transactions)); + /// Projected account balance at each month-end / year-end. + List<_BalancePoint> _balanceSeries({ + required List items, + required double currentBalance, + required int maxYears, + }) { + final today = _today; + if (_period == _PeriodMode.monthly) { + final year = _cursor.year; + const months = [ + 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D', + ]; + final out = <_BalancePoint>[]; + for (var m = 1; m <= 12; m++) { + final end = DateTime(year, m + 1, 0); + final balance = !end.isAfter(today) + ? currentBalance + : predictBalance( + currentBalance: currentBalance, + from: today, + throughDate: end, + items: items, + ); + out.add( + _BalancePoint( + key: DateTime(year, m), + label: months[m - 1], + balance: balance, + ), + ); + } + return out; + } + + final startYear = _today.year; + final endYear = _maxYear(maxYears); + final out = <_BalancePoint>[]; + for (var y = startYear; y <= endYear; y++) { + final end = DateTime(y, 12, 31); + final balance = !end.isAfter(today) + ? currentBalance + : predictBalance( + currentBalance: currentBalance, + from: today, + throughDate: end, + items: items, + ); + out.add( + _BalancePoint( + key: DateTime(y), + label: '$y', + balance: balance, + ), + ); + } + return out; + } + /// Resolve recurring amounts off the build path, yielding so nav stays live. void _ensureItemsLoaded({ required int cacheKey, @@ -261,7 +347,16 @@ class _LookAheadScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Look ahead'), - actions: bankAppBarActions(context: context), + actions: [ + IconButton( + tooltip: _showChart ? 'Show list' : 'Show chart', + onPressed: () => _toggleChart(maxYears), + icon: Icon( + _showChart ? Icons.view_list_outlined : Icons.calendar_month, + ), + ), + ...bankAppBarActions(context: context), + ], ), body: const LoadingView(message: 'Calculating look ahead…'), ); @@ -303,7 +398,16 @@ class _LookAheadScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Look ahead'), - actions: bankAppBarActions(context: context), + actions: [ + IconButton( + tooltip: _showChart ? 'Show list' : 'Show chart', + onPressed: () => _toggleChart(maxYears), + icon: Icon( + _showChart ? Icons.view_list_outlined : Icons.calendar_month, + ), + ), + ...bankAppBarActions(context: context), + ], ), body: ResponsiveBody( child: ListView( @@ -321,10 +425,8 @@ class _LookAheadScreenState extends State { canGoForward: _canGoForward(maxYears), onBack: () => _go(-1, maxYears), onForward: () => _go(1, maxYears), - backTooltip: - _period == _PeriodMode.yearly ? 'Previous year' : 'Previous month', - forwardTooltip: - _period == _PeriodMode.yearly ? 'Next year' : 'Next month', + backTooltip: _navByYear ? 'Previous year' : 'Previous month', + forwardTooltip: _navByYear ? 'Next year' : 'Next month', ), const SizedBox(height: 12), Material( @@ -408,82 +510,110 @@ class _LookAheadScreenState extends State { ), ], ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: Text( - 'Recurring spendings', - style: theme.textTheme.titleMedium, - ), - ), - TextButton.icon( - onPressed: () => showCreateRecurringTemplateSheet(context), - icon: const Icon(Icons.add, size: 18), - label: const Text('New'), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: _SummaryCard( - icon: Icons.south_west, - iconBackground: AppTheme.accent.withValues(alpha: 0.18), - iconColor: AppTheme.accent, - amount: formatMoney(monthlyIncome, currency), - label: 'Monthly income', - ), - ), - const SizedBox(width: 12), - Expanded( - child: _SummaryCard( - icon: Icons.north_east, - iconBackground: AppTheme.expense.withValues(alpha: 0.18), - iconColor: AppTheme.expense, - amount: formatMoney(monthlyExpense, currency), - label: 'Monthly expense', - ), - ), - ], - ), - const SizedBox(height: 10), - if (items.isEmpty) - const EmptyState( - icon: Icons.event_repeat_outlined, - title: 'No recurring spendings', - message: - 'Open a transaction and tap Create recurring spending, ' - 'or use New to pick a transaction or label average.', - ) - else - Card( - color: colors.surfaceContainer, - elevation: 0, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - for (var i = 0; i < items.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - indent: 16, - color: colors.outlineVariant.withValues(alpha: 0.4), - ), - _TemplateTile( - item: items[i], - template: - meta.recurringTemplateById(items[i].templateId), - transactions: bank.transactions, - ), - ], - ], + if (_showChart) ...[ + const SizedBox(height: 16), + _LookAheadBalanceChart( + points: _balanceSeries( + items: items, + currentBalance: currentAmount, + maxYears: maxYears, ), + period: _period, + currency: currency, ), + const SizedBox(height: 8), + Text( + _period == _PeriodMode.yearly + ? 'Projected account balance by year' + : 'Projected account balance by month', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ] else ...[ + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: Text( + 'Recurring spendings', + style: theme.textTheme.titleMedium, + ), + ), + TextButton.icon( + onPressed: () => showCreateRecurringTemplateSheet(context), + icon: const Icon(Icons.add, size: 18), + label: const Text('New'), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _SummaryCard( + icon: Icons.south_west, + iconBackground: AppTheme.accent.withValues(alpha: 0.18), + iconColor: AppTheme.accent, + amount: formatMoney(monthlyIncome, currency), + label: 'Monthly income', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryCard( + icon: Icons.north_east, + iconBackground: AppTheme.expense.withValues(alpha: 0.18), + iconColor: AppTheme.expense, + amount: formatMoney(monthlyExpense, currency), + label: 'Monthly expense', + ), + ), + ], + ), + const SizedBox(height: 10), + _SavingsCard( + monthlySavings: monthlyIncome - monthlyExpense, + currency: currency, + ), + const SizedBox(height: 10), + if (items.isEmpty) + const EmptyState( + icon: Icons.event_repeat_outlined, + title: 'No recurring spendings', + message: + 'Open a transaction and tap Create recurring spending, ' + 'or use New to pick a transaction or label average.', + ) + else + Card( + color: colors.surfaceContainer, + elevation: 0, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 16, + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + _TemplateTile( + item: items[i], + template: + meta.recurringTemplateById(items[i].templateId), + transactions: bank.transactions, + ), + ], + ], + ), + ), + ], ], ), ), @@ -491,6 +621,315 @@ class _LookAheadScreenState extends State { } } +class _LookAheadBalanceChart extends StatelessWidget { + const _LookAheadBalanceChart({ + required this.points, + required this.period, + required this.currency, + }); + + final List<_BalancePoint> points; + final _PeriodMode period; + final String currency; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + if (points.isEmpty) { + return Card( + color: colors.surfaceContainer, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No projected balance for this period', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + var minY = points.first.balance; + var maxY = points.first.balance; + for (final p in points) { + if (p.balance < minY) minY = p.balance; + if (p.balance > maxY) maxY = p.balance; + } + if (minY == maxY) { + minY -= 1; + maxY += 1; + } + final pad = (maxY - minY).abs() * 0.12; + final safeMin = minY - pad; + final safeMax = maxY + pad; + final interval = ((safeMax - safeMin) / 3).abs(); + final safeInterval = interval <= 0 ? 1.0 : interval; + + final spots = [ + for (var i = 0; i < points.length; i++) + FlSpot(i.toDouble(), points[i].balance), + ]; + + return Card( + color: colors.surfaceContainer, + elevation: 0, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Projected balance', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: LineChart( + LineChartData( + minX: 0, + maxX: (points.length - 1).toDouble(), + minY: safeMin, + maxY: safeMax, + lineTouchData: LineTouchData( + handleBuiltInTouches: true, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touched) { + return [ + for (final spot in touched) + if (spot.x.round() >= 0 && + spot.x.round() < points.length) + LineTooltipItem( + '${points[spot.x.round()].label}\n' + '${formatMoney(points[spot.x.round()].balance, currency)}', + theme.textTheme.labelMedium!.copyWith( + color: colors.onInverseSurface, + fontWeight: FontWeight.w600, + ), + ), + ]; + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 44, + interval: safeInterval, + getTitlesWidget: (value, meta) { + if ((value - safeMax).abs() < 0.01 || + (value - safeMin).abs() < 0.01) { + return const SizedBox.shrink(); + } + return Text( + _compactMoney(value), + style: theme.textTheme.labelSmall, + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.round(); + if (index < 0 || index >= points.length) { + return const SizedBox.shrink(); + } + if (period == _PeriodMode.yearly && + points.length > 8 && + index % 2 == 1) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + points[index].label, + style: theme.textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: safeInterval, + getDrawingHorizontalLine: (value) => FlLine( + color: colors.outlineVariant.withValues(alpha: 0.35), + strokeWidth: 1, + dashArray: const [4, 4], + ), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.18, + color: colors.primary, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) { + return FlDotCirclePainter( + radius: 3.5, + color: colors.primary, + strokeWidth: 0, + ); + }, + ), + belowBarData: BarAreaData( + show: true, + color: colors.primary.withValues(alpha: 0.14), + ), + ), + ], + ), + duration: Duration.zero, + ), + ), + ], + ), + ), + ); + } +} + +class _SavingsCard extends StatelessWidget { + const _SavingsCard({ + required this.monthlySavings, + required this.currency, + }); + + final double monthlySavings; + final String currency; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final yearly = monthlySavings * 12; + final positive = monthlySavings >= 0; + final tone = positive ? AppTheme.accent : AppTheme.expense; + + return Card( + color: colors.surfaceContainer, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.savings_outlined, color: tone, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: _SavingsMetric( + label: 'Monthly savings', + amount: formatMoney(monthlySavings, currency), + color: tone, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SavingsMetric( + label: 'Yearly savings', + amount: formatMoney(yearly, currency), + color: tone, + alignEnd: true, + ), + ), + ], + ), + ), + ); + } +} + +class _SavingsMetric extends StatelessWidget { + const _SavingsMetric({ + required this.label, + required this.amount, + required this.color, + this.alignEnd = false, + }); + + final String label; + final String amount; + final Color color; + final bool alignEnd; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Column( + crossAxisAlignment: + alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + amount, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ); + } +} + +String _compactMoney(double value) { + final abs = value.abs(); + if (abs >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M'; + if (abs >= 1000) return '${(value / 1000).toStringAsFixed(1)}k'; + if (abs >= 100) return value.toStringAsFixed(0); + return value.toStringAsFixed(abs < 10 && abs > 0 ? 1 : 0); +} + class _PeriodToolbar extends StatelessWidget { const _PeriodToolbar({ required this.period,