diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index 13b28ac..f56ba80 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -642,7 +642,7 @@ class TxMetaController extends ChangeNotifier { _schedulePersist(); } - /// Recurring look-ahead templates (manual + auto from marked txs). + /// Explicit look-ahead recurring spendings (never auto-linked from tx flags). List get recurringTemplates { final list = _recurringTemplates.values.toList(growable: false); list.sort((a, b) { @@ -656,6 +656,14 @@ class TxMetaController extends ChangeNotifier { RecurringTemplate? recurringTemplateById(String id) => _recurringTemplates[id]; + /// True when a spending already references this bank transaction. + bool hasRecurringSpendingForTx(String txId) { + for (final tpl in _recurringTemplates.values) { + if (tpl.sourceTxId == txId) return true; + } + return false; + } + void upsertRecurringTemplate(RecurringTemplate template) { if (template.id.isEmpty) return; _recurringTemplates[template.id] = template; @@ -668,42 +676,23 @@ class TxMetaController extends ChangeNotifier { _schedulePersist(invalidateLabels: false); } - /// Backfill auto templates for txs already marked recurring (upgrade path). - void ensureAutoRecurringTemplates(List transactions) { - var changed = false; - final recurringIds = {}; - for (final tx in transactions) { - if (!metaFor(tx).recurring) continue; - recurringIds.add(tx.id); - final id = RecurringTemplate.autoIdForTx(tx.id); - if (_recurringTemplates.containsKey(id)) continue; - _recurringTemplates[id] = RecurringTemplate( - id: id, - name: tx.description, - dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28), - currency: tx.currency, - source: RecurringTemplateSource.transaction, - amountRule: RecurringAmountRule.lastValue, - merchantKey: tx.merchantKey, - sourceTxId: tx.id, - avgCount: 5, - autoFromRecurring: true, - ); - changed = true; - } - // Drop orphaned auto templates whose source is no longer recurring. - final orphanIds = [ - for (final e in _recurringTemplates.entries) - if (e.value.autoFromRecurring && - (e.value.sourceTxId == null || - !recurringIds.contains(e.value.sourceTxId))) - e.key, - ]; - for (final id in orphanIds) { - _recurringTemplates.remove(id); - changed = true; - } - if (changed) _schedulePersist(invalidateLabels: false); + /// Create a spending seeded from [tx] (last-value amount rule). + RecurringTemplate addRecurringSpendingFromTx(BankTransaction tx) { + final id = 'rt_${DateTime.now().microsecondsSinceEpoch.toRadixString(16)}'; + final tpl = RecurringTemplate( + id: id, + name: tx.description, + dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28), + currency: tx.currency, + source: RecurringTemplateSource.transaction, + amountRule: RecurringAmountRule.lastValue, + merchantKey: tx.merchantKey, + sourceTxId: tx.id, + avgCount: 5, + autoFromRecurring: false, + ); + upsertRecurringTemplate(tpl); + return tpl; } Future setRecurring(BankTransaction tx, bool recurring) async { @@ -715,39 +704,6 @@ class TxMetaController extends ChangeNotifier { recurring: recurring, internalTransfer: existing.internalTransfer || current.internalTransfer, ); - if (recurring) { - final id = RecurringTemplate.autoIdForTx(tx.id); - final prev = _recurringTemplates[id]; - _recurringTemplates[id] = RecurringTemplate( - id: id, - name: prev?.name ?? tx.description, - dayOfMonth: prev?.dayOfMonth ?? - (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28), - currency: prev?.currency ?? tx.currency, - source: RecurringTemplateSource.transaction, - amountRule: prev?.amountRule ?? RecurringAmountRule.lastValue, - merchantKey: prev?.merchantKey.isNotEmpty == true - ? prev!.merchantKey - : tx.merchantKey, - sourceTxId: tx.id, - labelId: prev?.labelId, - avgCount: prev?.avgCount ?? 5, - autoFromRecurring: true, - ); - } else { - final id = RecurringTemplate.autoIdForTx(tx.id); - final existingTpl = _recurringTemplates[id]; - if (existingTpl != null && existingTpl.autoFromRecurring) { - _recurringTemplates.remove(id); - } - // Also drop auto templates that still point at this tx under another id. - _recurringTemplates.removeWhere( - (_, tpl) => - tpl.autoFromRecurring && - tpl.sourceTxId == tx.id && - tpl.id != id, - ); - } _schedulePersist(invalidateLabels: false); } @@ -835,10 +791,6 @@ class TxMetaController extends ChangeNotifier { _schedulePersist(); } - Future toggleRecurring(BankTransaction tx) { - return setRecurring(tx, !metaFor(tx).recurring); - } - Future toggleInternalTransfer(BankTransaction tx) { return setInternalTransfer(tx, !metaFor(tx).internalTransfer); } diff --git a/lib/screens/look_ahead_screen.dart b/lib/screens/look_ahead_screen.dart index afb9b0e..adfd31e 100644 --- a/lib/screens/look_ahead_screen.dart +++ b/lib/screens/look_ahead_screen.dart @@ -34,7 +34,6 @@ class LookAheadScreen extends StatefulWidget { class _LookAheadScreenState extends State { var _period = _PeriodMode.monthly; late DateTime _cursor; - var _didBackfill = false; List? _cachedItems; int? _itemsCacheKey; @@ -135,16 +134,6 @@ class _LookAheadScreenState extends State { final theme = Theme.of(context); final colors = theme.colorScheme; - if (!_didBackfill && bank.transactions.isNotEmpty) { - _didBackfill = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context - .read() - .ensureAutoRecurringTemplates(bank.transactions); - }); - } - final balance = bank.balance; final currency = balance?.currency ?? (bank.transactions.isNotEmpty @@ -190,6 +179,17 @@ class _LookAheadScreenState extends State { } } + // 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'), @@ -303,7 +303,7 @@ class _LookAheadScreenState extends State { children: [ Expanded( child: Text( - 'Recurring templates', + 'Recurring spendings', style: theme.textTheme.titleMedium, ), ), @@ -317,8 +317,8 @@ class _LookAheadScreenState extends State { const SizedBox(height: 4), Text( items.isEmpty - ? 'Mark transactions as recurring, or create a template.' - : '${items.length} template${items.length == 1 ? '' : 's'} · ' + ? '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( @@ -326,13 +326,37 @@ class _LookAheadScreenState extends State { ), ), 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 templates', + title: 'No recurring spendings', message: - 'Mark a transaction as recurring, or tap New to base a ' - 'template on a transaction or label average.', + 'Open a transaction and tap Create recurring spending, ' + 'or use New to pick a transaction or label average.', ) else Card( @@ -584,7 +608,7 @@ class _TemplateTile extends StatelessWidget { return ListTile( leading: IconButton( - tooltip: 'Edit template', + tooltip: 'Edit spending', icon: Icon(Icons.tune, color: colors.primary), onPressed: tpl == null ? null diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index 245471d..6fe794d 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -11,6 +11,7 @@ import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/helpers.dart'; import '../widgets/label_parent_picker.dart'; +import '../widgets/recurring_template_sheet.dart'; import '../widgets/responsive_body.dart'; import '../widgets/similar_match_dialog.dart'; @@ -409,17 +410,28 @@ class _TransactionDetailScreenState extends State { ), ), const SizedBox(height: 8), - SwitchListTile( + ListTile( contentPadding: EdgeInsets.zero, - secondary: const Icon(Icons.autorenew), - title: const Text('Recurring'), - subtitle: const Text('Salary, mortgage, subscriptions…'), - value: meta.recurring, - onChanged: _busy + leading: Icon( + Icons.event_repeat, + color: colors.primary, + ), + title: const Text('Create recurring spending'), + subtitle: Text( + metaController.hasRecurringSpendingForTx(tx.id) + ? 'Already on Look ahead — tap to add another' + : 'Add this transaction to Look ahead', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: _busy ? null - : (value) { - context.read().setRecurring(tx, value); - }, + : () => showCreateRecurringTemplateSheet( + context, + initialTx: tx, + ), ), SwitchListTile( contentPadding: EdgeInsets.zero, diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 9bc2bf2..ef533fc 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -61,7 +61,6 @@ class _TransactionsScreenState extends State { var _showCalendar = false; var _flowFilter = TxFlowFilter.all; var _amountRange = const TxAmountRange(); - var _recurringOnly = false; /// Query actually used for filtering (Enter, or live once ≥3 chars). String _activeQuery = ''; @@ -97,13 +96,11 @@ class _TransactionsScreenState extends State { bool get _hasListFilters => _labelFilters.isNotEmpty || _flowFilter != TxFlowFilter.all || - _amountRange.isActive || - _recurringOnly; + _amountRange.isActive; bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) { if (!txMatchesFlow(tx, _flowFilter)) return false; if (!txMatchesAmountRange(tx, _amountRange)) return false; - if (_recurringOnly && !meta.metaFor(tx).recurring) return false; return true; } @@ -172,7 +169,6 @@ class _TransactionsScreenState extends State { amountRange: _amountRange, query: _activeQuery, monthOnly: _searchInMonthOnly, - recurringOnly: _recurringOnly, ), ); if (result == null || !mounted) return; @@ -184,7 +180,6 @@ class _TransactionsScreenState extends State { _flowFilter = result.flow; _amountRange = result.amountRange; _searchInMonthOnly = result.monthOnly; - _recurringOnly = result.recurringOnly; if (!_searchInMonthOnly) { _showCalendar = false; _selectedDay = null; @@ -682,15 +677,6 @@ class _TransactionsScreenState extends State { ); }, ), - if (_recurringOnly) - InputChip( - visualDensity: VisualDensity.compact, - avatar: const Icon(Icons.event_repeat, size: 14), - label: const Text('Recurring'), - onDeleted: () { - setState(() => _recurringOnly = false); - }, - ), for (final id in _labelFilters) if (id == kUncategorizedFilterId) InputChip( @@ -968,7 +954,6 @@ class _CompactTransactionTile extends StatelessWidget { formatIsoDate(tx.date), if (labels.isNotEmpty) labels.map((l) => l.name).join(', '), - if (meta.recurring) 'Recurring', if (meta.internalTransfer) 'Transfer', if (meta.hasNotes) 'Note', if (tx.pending) 'Pending', diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index b61ae5f..461975d 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -19,7 +19,6 @@ class TransactionFilterSelection { this.amountRange = const TxAmountRange(), this.query = '', this.monthOnly = false, - this.recurringOnly = false, }); final Set labelIds; @@ -30,14 +29,10 @@ class TransactionFilterSelection { /// When true, list is limited to the selected calendar month. final bool monthOnly; - /// When true, only transactions marked recurring are shown. - final bool recurringOnly; - bool get hasActiveExtras => flow != TxFlowFilter.all || amountRange.isActive || - query.trim().isNotEmpty || - recurringOnly; + query.trim().isNotEmpty; } /// How label series are drawn on the overview chart. @@ -502,7 +497,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { late final Set _selected; late TxFlowFilter _flow; late bool _monthOnly; - late bool _recurringOnly; late final TextEditingController _queryController; late final TextEditingController _labelSearchController; late final TextEditingController _exactAmountController; @@ -516,7 +510,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { _selected = Set.of(initial.labelIds); _flow = initial.flow; _monthOnly = initial.monthOnly; - _recurringOnly = initial.recurringOnly; _queryController = TextEditingController(text: initial.query); _labelSearchController = TextEditingController(); @@ -602,7 +595,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { amountRange: _amountResult, query: _queryController.text.trim(), monthOnly: _monthOnly, - recurringOnly: _recurringOnly, ), ); } @@ -718,24 +710,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { ), ], ), - const SizedBox(height: 8), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - dense: true, - value: _recurringOnly, - onChanged: (value) { - setState(() => _recurringOnly = value ?? false); - }, - controlAffinity: ListTileControlAffinity.leading, - secondary: const Icon(Icons.event_repeat, size: 20), - title: const Text('Recurring only'), - subtitle: Text( - 'Only transactions marked as recurring', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ), const SizedBox(height: 14), Text('Amount', style: sectionStyle), const SizedBox(height: 8), diff --git a/lib/widgets/recurring_template_sheet.dart b/lib/widgets/recurring_template_sheet.dart index bee6368..884a5cc 100644 --- a/lib/widgets/recurring_template_sheet.dart +++ b/lib/widgets/recurring_template_sheet.dart @@ -12,13 +12,16 @@ import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../utils/look_ahead.dart'; -Future showCreateRecurringTemplateSheet(BuildContext context) { +Future showCreateRecurringTemplateSheet( + BuildContext context, { + BankTransaction? initialTx, +}) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, showDragHandle: true, - builder: (context) => const _RecurringTemplateSheet(), + builder: (context) => _RecurringTemplateSheet(initialTx: initialTx), ); } @@ -36,9 +39,10 @@ Future showEditRecurringTemplateSheet( } class _RecurringTemplateSheet extends StatefulWidget { - const _RecurringTemplateSheet({this.existing}); + const _RecurringTemplateSheet({this.existing, this.initialTx}); final RecurringTemplate? existing; + final BankTransaction? initialTx; @override State<_RecurringTemplateSheet> createState() => @@ -71,15 +75,28 @@ class _RecurringTemplateSheetState extends State<_RecurringTemplateSheet> { _labelId = existing?.labelId; _currency = existing?.currency ?? 'EUR'; - if (existing?.sourceTxId != null) { + BankTransaction? seed = widget.initialTx; + if (seed == null && existing?.sourceTxId != null) { final txs = context.read().transactions; for (final tx in txs) { if (tx.id == existing!.sourceTxId) { - _pickedTx = tx; + seed = tx; break; } } } + if (seed != null) { + _pickedTx = seed; + _currency = seed.currency; + _source = RecurringTemplateSource.transaction; + if (_nameController.text.trim().isEmpty) { + _nameController.text = seed.description; + } + if (existing == null) { + _dayController.text = + '${(DateTime.tryParse(seed.date)?.day ?? 1).clamp(1, 28)}'; + } + } } @override @@ -252,7 +269,9 @@ class _RecurringTemplateSheetState extends State<_RecurringTemplateSheet> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - widget.existing == null ? 'New recurring template' : 'Edit template', + widget.existing == null + ? 'New recurring spending' + : 'Edit recurring spending', style: theme.textTheme.titleLarge, ), const SizedBox(height: 16),