/// Look ahead: projected balance from recurring templates through a date. library; import 'dart:async'; import 'package:fl_chart/fl_chart.dart'; 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/transactions_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/loading_view.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 _ItemsSnapshot { const _ItemsSnapshot({required this.key, required this.items}); final int key; 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}); static const tabIndex = 2; @override State createState() => _LookAheadScreenState(); } class _LookAheadScreenState extends State { var _period = _PeriodMode.monthly; late DateTime _cursor; _ItemsSnapshot? _snapshot; int? _loadingKey; int _computeGen = 0; var _showChart = false; @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 { // 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 (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}'; return formatMonthYear(_cursor); } DateTime _maxMonthFor(int maxYears) { final t = _today; return DateTime(t.year + maxYears, t.month); } int _maxYear(int maxYears) => _today.year + maxYears; bool get _navByYear => _showChart || _period == _PeriodMode.yearly; bool _canGoBack() { if (_navByYear) return _cursor.year > _today.year; return monthStart(_cursor).isAfter(_minMonth); } bool _canGoForward(int maxYears) { if (_navByYear) return _cursor.year < _maxYear(maxYears); return monthStart(_cursor).isBefore(_maxMonthFor(maxYears)); } void _clampCursor(int 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; } } void _setPeriod(_PeriodMode mode, int maxYears) { if (mode == _period) return; setState(() { _period = mode; if (mode == _PeriodMode.yearly || _showChart) { _cursor = DateTime(_cursor.year); _clampCursor(maxYears); } else { final month = DateTime(_cursor.year, _today.month); _cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month); _clampCursor(maxYears); } }); } void _go(int delta, int maxYears) { setState(() { if (_navByYear) { final next = DateTime(_cursor.year + delta); if (next.year < _today.year || next.year > _maxYear(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; } }); } 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, required TxMetaController meta, required BankController bank, }) { if (_snapshot?.key == cacheKey || _loadingKey == cacheKey) return; _loadingKey = cacheKey; final gen = ++_computeGen; final templates = List.of(meta.recurringTemplates); final transactions = bank.transactions; unawaited(() async { // Let the spinner / tab switch paint before heavy work. await Future.delayed(Duration.zero); if (!mounted || gen != _computeGen) return; final items = []; for (var i = 0; i < templates.length; i++) { final tpl = templates[i]; items.add( RecurringItem( templateId: tpl.id, description: tpl.name, amount: resolveRecurringAmount( template: tpl, transactions: transactions, isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, labelIdsFor: meta.labelIdsFor, ), currency: tpl.currency, dayOfMonth: tpl.dayOfMonth.clamp(1, 28), ruleSummary: recurringRuleSummary( template: tpl, labelName: (id) => meta.labelById(id)?.name ?? 'label', ), sourceTxId: tpl.sourceTxId, labelIds: [ if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!, ], ), ); // Yield often so bottom-nav taps are not starved. if (i % 2 == 1) { await Future.delayed(Duration.zero); if (!mounted || gen != _computeGen) return; } } if (!mounted || gen != _computeGen) return; setState(() { _snapshot = _ItemsSnapshot(key: cacheKey, items: items); if (_loadingKey == cacheKey) _loadingKey = null; }); }()); } @override Widget build(BuildContext context) { final active = ShellTabScope.of(context) == LookAheadScreen.tabIndex; if (!active) { if (_loadingKey != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (ShellTabScope.of(context) == LookAheadScreen.tabIndex) return; _computeGen++; _loadingKey = null; }); } 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 = _itemsCacheKey(meta, bank); if (_snapshot?.key != cacheKey && _loadingKey != cacheKey) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) return; _ensureItemsLoaded(cacheKey: cacheKey, meta: meta, bank: bank); }); } final snapshot = _snapshot; final ready = snapshot != null && snapshot.key == cacheKey; if (!ready) { return Scaffold( appBar: AppBar( title: const Text('Look ahead'), 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…'), ); } final items = snapshot.items; 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: [ 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( 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: _navByYear ? 'Previous year' : 'Previous month', forwardTooltip: _navByYear ? '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', ), ), ], ), 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, ), ], ], ), ), ], ], ), ), ); } } 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, 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; } void _openTransactions(BuildContext context) { final labelIds = item.labelIds.toSet(); SimilarMatchCriteria? similar; TxFlowFilter? flow; String? query; if (labelIds.isEmpty) { final tx = _sourceTx; if (tx != null) { // Same matching as Look ahead / apply-to-similar for this source tx. similar = SimilarMatchCriteria( pattern: defaultSimilarPattern(tx.description), flow: tx.amount < 0 ? TxFlowFilter.expense : tx.amount > 0 ? TxFlowFilter.income : TxFlowFilter.all, ); flow = similar.flow; } else if (template != null && template!.merchantKey.isNotEmpty) { query = template!.merchantKey; } else { query = item.description; } } Navigator.of(context).push( MaterialPageRoute( builder: (_) => TransactionsScreen( initialLabelIds: labelIds, initialLabelMatch: LabelMatchMode.or, initialQuery: query, initialFlow: flow, initialSimilarMatch: similar, initialSearchInMonthOnly: false, ), ), ); } @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: () => _openTransactions(context), ); } }