/// Look ahead: projected balance from recurring templates through a date. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../screens/transaction_detail_screen.dart'; import '../utils/app_theme.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/recurring_template_sheet.dart'; import '../widgets/responsive_body.dart'; import '../widgets/shell_tab_scope.dart'; import '../widgets/skip_build_when_covered.dart'; enum _PeriodMode { monthly, yearly } class LookAheadScreen extends StatefulWidget { const LookAheadScreen({super.key}); static const tabIndex = 2; @override State createState() => _LookAheadScreenState(); } class _LookAheadScreenState extends State { var _period = _PeriodMode.monthly; late DateTime _cursor; List? _cachedItems; int? _itemsCacheKey; @override void initState() { super.initState(); final today = dateOnly(DateTime.now()); // Default: end of next calendar month. _cursor = monthStart(DateTime(today.year, today.month + 1)); } DateTime get _today => dateOnly(DateTime.now()); DateTime get _minMonth => monthStart(_today); /// Inclusive end date for the selected period. DateTime get _through { if (_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}'; return formatMonthYear(_cursor); } DateTime _maxMonthFor(int maxYears) { final t = _today; return DateTime(t.year + maxYears, t.month); } bool _canGoBack() { if (_period == _PeriodMode.yearly) { return _cursor.year > _today.year; } return monthStart(_cursor).isAfter(_minMonth); } bool _canGoForward(int maxYears) { if (_period == _PeriodMode.yearly) { return _cursor.year < _today.year + 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); } } else { final maxMonth = _maxMonthFor(maxYears); if (_cursor.isAfter(maxMonth)) _cursor = maxMonth; } } void _setPeriod(_PeriodMode mode, int maxYears) { if (mode == _period) return; setState(() { _period = mode; if (mode == _PeriodMode.yearly) { _cursor = DateTime(_cursor.year); if (_cursor.year < _today.year) _cursor = DateTime(_today.year); if (_cursor.year > _today.year + maxYears) { _cursor = DateTime(_today.year + 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; } }); } void _go(int delta, int maxYears) { setState(() { if (_period == _PeriodMode.yearly) { final next = DateTime(_cursor.year + delta); if (next.year < _today.year || next.year > _today.year + maxYears) { return; } _cursor = next; } else { final next = addMonths(monthStart(_cursor), delta); final maxMonth = _maxMonthFor(maxYears); if (next.isBefore(_minMonth) || next.isAfter(maxMonth)) return; _cursor = next; } }); } @override Widget build(BuildContext context) { if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) { return const SizedBox.expand(); } return SkipBuildWhenCovered( builder: (context) => _buildContent(context), ); } Widget _buildContent(BuildContext context) { final bank = context.watch(); final meta = context.watch(); final maxYears = context.watch().lookAheadMaxYears; final maxMonth = _maxMonthFor(maxYears); final needsClamp = _period == _PeriodMode.yearly ? _cursor.year > _today.year + maxYears : _cursor.isAfter(maxMonth); if (needsClamp) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() => _clampCursor(maxYears)); }); } final theme = Theme.of(context); final colors = theme.colorScheme; final balance = bank.balance; final currency = balance?.currency ?? (bank.transactions.isNotEmpty ? bank.transactions.first.currency : 'EUR'); final currentAmount = balance?.amount ?? 0; final today = _today; final through = _through; final cacheKey = Object.hash( meta.storeRevision, identityHashCode(bank.transactions), ); if (_cachedItems == null || _itemsCacheKey != cacheKey) { _itemsCacheKey = cacheKey; _cachedItems = recurringItemsFromTemplates( templates: meta.recurringTemplates, transactions: bank.transactions, isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, labelIdsFor: meta.labelIdsFor, labelName: (id) => meta.labelById(id)?.name ?? 'label', ); } final items = _cachedItems!; final projected = projectedOccurrences( items: items, from: today, to: through, ); final predicted = projected.fold( currentAmount, (sum, row) => sum + row.item.amount, ); final delta = predicted - currentAmount; var income = 0.0; var expenses = 0.0; for (final row in projected) { if (row.item.amount >= 0) { income += row.item.amount; } else { expenses += -row.item.amount; } } // One occurrence of each template per month (template run-rate). var monthlyIncome = 0.0; var monthlyExpense = 0.0; for (final item in items) { if (item.amount >= 0) { monthlyIncome += item.amount; } else { monthlyExpense += -item.amount; } } return Scaffold( appBar: AppBar( title: const Text('Look ahead'), actions: bankAppBarActions(context: context), ), body: ResponsiveBody( child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(0, 8, 0, 32), children: [ _PeriodToolbar( period: _period, onPeriodChanged: (mode) => _setPeriod(mode, maxYears), ), const SizedBox(height: 10), _PeriodNavigator( title: _periodTitle, canGoBack: _canGoBack(), 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', ), const SizedBox(height: 12), Material( color: Colors.transparent, child: Ink( width: double.infinity, padding: const EdgeInsets.all(24), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), gradient: LinearGradient( colors: [colors.primary, colors.tertiary], ), boxShadow: [ BoxShadow( color: colors.primary.withValues(alpha: 0.28), blurRadius: 20, offset: const Offset(0, 10), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Projected balance', style: theme.textTheme.labelMedium?.copyWith( color: colors.onPrimary.withValues(alpha: 0.8), ), ), const SizedBox(height: 4), Text( 'As of ${_formatLongDate(through)}', style: theme.textTheme.titleMedium?.copyWith( color: colors.onPrimary, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 12), Text( formatMoney(predicted, currency), style: theme.textTheme.headlineMedium?.copyWith( color: colors.onPrimary, ), ), const SizedBox(height: 8), Text( balance == null ? 'No current balance loaded — projection starts at 0' : 'Now ${formatMoney(currentAmount, currency)}' ' · ${delta >= 0 ? '+' : ''}' '${formatMoney(delta, currency)} by then', style: theme.textTheme.labelMedium?.copyWith( color: colors.onPrimary.withValues(alpha: 0.9), ), ), ], ), ), ), const SizedBox(height: 14), Row( children: [ Expanded( child: _SummaryCard( icon: Icons.south_west, iconBackground: AppTheme.accent.withValues(alpha: 0.18), iconColor: AppTheme.accent, amount: formatMoney(income, currency), label: 'Income', ), ), const SizedBox(width: 12), Expanded( child: _SummaryCard( icon: Icons.north_east, iconBackground: AppTheme.expense.withValues(alpha: 0.18), iconColor: AppTheme.expense, amount: formatMoney(expenses, currency), label: 'Expenses', ), ), ], ), 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: 4), Text( items.isEmpty ? 'Create a spending from a transaction, or tap New.' : '${items.length} spending${items.length == 1 ? '' : 's'} · ' 'projected income ${formatMoney(income, currency)} · ' 'expenses ${formatMoney(expenses, currency)}.', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), 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, ), ], ], ), ), ], ), ), ); } } class _PeriodToolbar extends StatelessWidget { const _PeriodToolbar({ required this.period, required this.onPeriodChanged, }); final _PeriodMode period; final ValueChanged<_PeriodMode> onPeriodChanged; static const _segmentStyle = ButtonStyle( visualDensity: VisualDensity(horizontal: -4, vertical: -3), tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)), minimumSize: WidgetStatePropertyAll(Size(0, 32)), ); @override Widget build(BuildContext context) { return Align( alignment: Alignment.centerLeft, child: SegmentedButton<_PeriodMode>( showSelectedIcon: false, style: _segmentStyle, segments: const [ ButtonSegment( value: _PeriodMode.monthly, label: Text('Month'), ), ButtonSegment( value: _PeriodMode.yearly, label: Text('Year'), ), ], selected: {period}, onSelectionChanged: (selection) { onPeriodChanged(selection.first); }, ), ); } } class _PeriodNavigator extends StatelessWidget { const _PeriodNavigator({ required this.title, required this.canGoBack, required this.canGoForward, required this.onBack, required this.onForward, required this.backTooltip, required this.forwardTooltip, }); final String title; final bool canGoBack; final bool canGoForward; final VoidCallback onBack; final VoidCallback onForward; final String backTooltip; final String forwardTooltip; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( children: [ IconButton( tooltip: backTooltip, onPressed: canGoBack ? onBack : null, visualDensity: const VisualDensity(horizontal: -4, vertical: -4), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, icon: const Icon(Icons.chevron_left, size: 22), ), Expanded( child: Text( title, textAlign: TextAlign.center, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ), IconButton( tooltip: forwardTooltip, onPressed: canGoForward ? onForward : null, visualDensity: const VisualDensity(horizontal: -4, vertical: -4), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, icon: const Icon(Icons.chevron_right, size: 22), ), ], ); } } 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.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: iconBackground, borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: iconColor, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( amount, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, color: colors.onSurface, ), ), const SizedBox(height: 2), Text( label.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( letterSpacing: 0.6, fontWeight: FontWeight.w600, color: colors.onSurfaceVariant, ), ), ], ), ), ], ), ), ); } } String _formatLongDate(DateTime date) { const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; return '${date.day} ${months[date.month - 1]} ${date.year}'; } class _TemplateTile extends StatelessWidget { const _TemplateTile({ required this.item, required this.template, required this.transactions, }); final RecurringItem item; final RecurringTemplate? template; final List transactions; BankTransaction? get _sourceTx { final id = item.sourceTxId; if (id == null) return null; for (final tx in transactions) { if (tx.id == id) return tx; } return null; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final income = item.amount >= 0; final tpl = template; return ListTile( leading: IconButton( tooltip: 'Edit spending', icon: Icon(Icons.tune, color: colors.primary), onPressed: tpl == null ? null : () => showEditRecurringTemplateSheet(context, template: tpl), ), title: Text( item.description, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( [ 'Day ${item.dayOfMonth}', if (item.ruleSummary.isNotEmpty) item.ruleSummary, ].join(' · '), style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), trailing: Text( formatMoney(item.amount, item.currency), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: income ? AppTheme.accent : AppTheme.expense, ), ), onTap: () { final tx = _sourceTx; if (tx == null) return; Navigator.of(context).push( MaterialPageRoute( builder: (_) => TransactionDetailScreen(tx: tx), ), ); }, ); } }