diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index 72ece5f..c712fc2 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -325,6 +325,126 @@ class TxExactTemplate { } } +/// How a [RecurringTemplate] resolves its projected amount. +enum RecurringAmountRule { + lastValue, + avgLastTxs, + labelAvgMonths, +} + +/// What a recurring template is anchored to. +enum RecurringTemplateSource { + transaction, + label, +} + +/// First-class look-ahead recurring template (manual or auto from marked txs). +class RecurringTemplate { + const RecurringTemplate({ + required this.id, + required this.name, + required this.dayOfMonth, + required this.currency, + required this.source, + required this.amountRule, + this.merchantKey = '', + this.sourceTxId, + this.labelId, + this.avgCount = 5, + this.autoFromRecurring = false, + }); + + final String id; + final String name; + final int dayOfMonth; + final String currency; + final RecurringTemplateSource source; + final RecurringAmountRule amountRule; + final String merchantKey; + final String? sourceTxId; + final String? labelId; + final int avgCount; + final bool autoFromRecurring; + + RecurringTemplate copyWith({ + String? id, + String? name, + int? dayOfMonth, + String? currency, + RecurringTemplateSource? source, + RecurringAmountRule? amountRule, + String? merchantKey, + String? sourceTxId, + bool clearSourceTxId = false, + String? labelId, + bool clearLabelId = false, + int? avgCount, + bool? autoFromRecurring, + }) { + return RecurringTemplate( + id: id ?? this.id, + name: name ?? this.name, + dayOfMonth: dayOfMonth ?? this.dayOfMonth, + currency: currency ?? this.currency, + source: source ?? this.source, + amountRule: amountRule ?? this.amountRule, + merchantKey: merchantKey ?? this.merchantKey, + sourceTxId: clearSourceTxId ? null : (sourceTxId ?? this.sourceTxId), + labelId: clearLabelId ? null : (labelId ?? this.labelId), + avgCount: avgCount ?? this.avgCount, + autoFromRecurring: autoFromRecurring ?? this.autoFromRecurring, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'dayOfMonth': dayOfMonth, + 'currency': currency, + 'source': source.name, + 'amountRule': amountRule.name, + 'merchantKey': merchantKey, + 'sourceTxId': sourceTxId, + 'labelId': labelId, + 'avgCount': avgCount, + 'autoFromRecurring': autoFromRecurring, + }; + + factory RecurringTemplate.fromJson(Map json) { + return RecurringTemplate( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? 'Recurring', + dayOfMonth: (json['dayOfMonth'] as num?)?.toInt().clamp(1, 28) ?? 1, + currency: json['currency'] as String? ?? 'EUR', + source: _parseTemplateSource(json['source'] as String?), + amountRule: _parseAmountRule(json['amountRule'] as String?), + merchantKey: json['merchantKey'] as String? ?? '', + sourceTxId: json['sourceTxId'] as String?, + labelId: json['labelId'] as String?, + avgCount: (json['avgCount'] as num?)?.toInt().clamp(1, 36) ?? 5, + autoFromRecurring: json['autoFromRecurring'] as bool? ?? false, + ); + } + + /// Stable id for auto templates linked to a bank transaction. + static String autoIdForTx(String txId) => 'auto_${txId.hashCode.toRadixString(16)}'; +} + +RecurringTemplateSource _parseTemplateSource(String? raw) { + return switch (raw) { + 'label' => RecurringTemplateSource.label, + _ => RecurringTemplateSource.transaction, + }; +} + +RecurringAmountRule _parseAmountRule(String? raw) { + return switch (raw) { + 'avgLastTxs' => RecurringAmountRule.avgLastTxs, + 'labelAvgMonths' => RecurringAmountRule.labelAvgMonths, + _ => RecurringAmountRule.lastValue, + }; +} + /// Strip bank noise so supermarket / POS lines group by merchant stem. /// /// Example: `BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr:…` → `ah campus` diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index f0efe99..08e0c81 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -158,7 +158,10 @@ class BankController extends ChangeNotifier { /// Union cloud txs into the local bank cache without dropping either side. /// /// Rows land in the API cache; CSV imports that match are deduped away. - Future applySyncedTransactions(List fromCloud) async { + Future applySyncedTransactions( + List fromCloud, { + bool notify = true, + }) async { if (fromCloud.isEmpty) return; _remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud); @@ -181,7 +184,7 @@ class BankController extends ChangeNotifier { await _persistCache(); await _persistImported(); - notifyListeners(); + if (notify) notifyListeners(); } /// Load status; if linked, also load balance + transactions. diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index e183ef3..6ebcba6 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -51,6 +51,8 @@ class SyncController extends ChangeNotifier { bool _queuedFullSync = false; Timer? _pushDebounce; Timer? _progressHold; + Timer? _uiNotifyThrottle; + var _uiNotifyQueued = false; String? _lastError; DateTime? _lastSyncedAt; String _lastSyncSummary = ''; @@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier { double _syncProgress = 0; bool _showProgressRing = false; bool _progressSuccess = false; + int _lastNotifiedProgressPct = -1; bool get isSyncing => _syncing; String? get lastError => _lastError; @@ -86,6 +89,25 @@ class SyncController extends ChangeNotifier { /// Ring turns green when this is true (sync finished successfully). bool get progressSuccess => _progressSuccess; + void _notifyUiNow() { + _uiNotifyThrottle?.cancel(); + _uiNotifyQueued = false; + _lastNotifiedProgressPct = (_syncProgress * 100).round(); + notifyListeners(); + } + + /// Coalesce high-frequency progress/status into ~10 Hz UI updates. + void _notifyUiThrottled() { + if (_uiNotifyQueued) return; + _uiNotifyQueued = true; + _uiNotifyThrottle?.cancel(); + _uiNotifyThrottle = Timer(const Duration(milliseconds: 100), () { + _uiNotifyQueued = false; + _lastNotifiedProgressPct = (_syncProgress * 100).round(); + notifyListeners(); + }); + } + void _announce(String message, {bool isProgress = false, bool isError = false}) { _feedbackEpoch++; _feedback = SyncFeedback( @@ -94,24 +116,38 @@ class SyncController extends ChangeNotifier { isProgress: isProgress, isError: isError, ); - notifyListeners(); + _notifyUiNow(); } void _setProgress(double value, {bool success = false}) { - _syncProgress = value.clamp(0.0, 1.0); + final next = value.clamp(0.0, 1.0); + final pct = (next * 100).round(); + _syncProgress = next; _showProgressRing = true; _progressSuccess = success; - notifyListeners(); + // Skip tiny progress moves — avatar only shows whole percents. + if (!success && + pct == _lastNotifiedProgressPct && + _showProgressRing) { + return; + } + if (success) { + _notifyUiNow(); + } else { + _notifyUiThrottled(); + } } void _setStatus(String message) { if (_statusDetail == message) return; _statusDetail = message; - notifyListeners(); + _notifyUiThrottled(); } void _finishProgress({required bool success}) { _progressHold?.cancel(); + _uiNotifyThrottle?.cancel(); + _uiNotifyQueued = false; _statusDetail = ''; if (success) { _setProgress(1, success: true); @@ -119,13 +155,15 @@ class SyncController extends ChangeNotifier { _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; - notifyListeners(); + _lastNotifiedProgressPct = -1; + _notifyUiNow(); }); } else { _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; - notifyListeners(); + _lastNotifiedProgressPct = -1; + _notifyUiNow(); } } @@ -138,12 +176,15 @@ class SyncController extends ChangeNotifier { if (!loggedIn) { _pushDebounce?.cancel(); _progressHold?.cancel(); + _uiNotifyThrottle?.cancel(); + _uiNotifyQueued = false; _lastError = null; _statusDetail = ''; _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; - notifyListeners(); + _lastNotifiedProgressPct = -1; + _notifyUiNow(); } } @@ -191,8 +232,11 @@ class SyncController extends ChangeNotifier { _setProgress(0.5); _setStatus('Merging…'); final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta); - await _meta.replaceStore(mergedMeta); - await _bank.applySyncedTransactions(remoteTxs); + // Apply without notifying — one rebuild after both stores are ready. + await _meta.replaceStore(mergedMeta, notify: false); + await _bank.applySyncedTransactions(remoteTxs, notify: false); + _meta.notifyListeners(); + _bank.notifyListeners(); _setProgress(0.55); _setStatus('Pushing…'); final metaSummary = await service.push( @@ -283,6 +327,7 @@ class SyncController extends ChangeNotifier { void dispose() { _pushDebounce?.cancel(); _progressHold?.cancel(); + _uiNotifyThrottle?.cancel(); _auth.removeListener(_onAuthChanged); _meta.onPersisted = null; super.dispose(); diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index bc9ce7b..13b28ac 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -19,6 +19,7 @@ class TxMetaController extends ChangeNotifier { }; _notesByMerchant = Map.of(loaded.notesByMerchant); _exactTemplates = Map.of(loaded.exactTemplates); + _recurringTemplates = Map.of(loaded.recurringTemplates); _customLabels = List.of(loaded.customLabels); _labelParents = Map.of(loaded.labelParents); } @@ -32,6 +33,7 @@ class TxMetaController extends ChangeNotifier { Map> _labelsByMerchant = {}; Map _notesByMerchant = {}; Map _exactTemplates = {}; + Map _recurringTemplates = {}; List _customLabels = []; Map _labelParents = {}; @@ -45,6 +47,7 @@ class TxMetaController extends ChangeNotifier { }, notesByMerchant: Map.of(_notesByMerchant), exactTemplates: Map.of(_exactTemplates), + recurringTemplates: Map.of(_recurringTemplates), customLabels: List.of(_customLabels), labelParents: Map.of(_labelParents), ); @@ -75,7 +78,10 @@ class TxMetaController extends ChangeNotifier { int get customLabelCount => _customLabels.length; /// Replace local store (e.g. after merge) and persist without re-triggering push. - Future replaceStore(TxMetaStoreData store) async { + Future replaceStore( + TxMetaStoreData store, { + bool notify = true, + }) async { _byId = Map.of(store.byId); _labelsByMerchant = { for (final e in store.labelsByMerchant.entries) @@ -83,10 +89,12 @@ class TxMetaController extends ChangeNotifier { }; _notesByMerchant = Map.of(store.notesByMerchant); _exactTemplates = Map.of(store.exactTemplates); + _recurringTemplates = Map.of(store.recurringTemplates); _customLabels = List.of(store.customLabels); _labelParents = Map.of(store.labelParents); _invalidateLabelCache(); - notifyListeners(); + _storeRevision++; + if (notify) notifyListeners(); await _writeStore(notifySync: false); } @@ -396,12 +404,16 @@ class TxMetaController extends ChangeNotifier { String? pattern, SimilarMatchCriteria? criteria, }) { - return similarTo( - prototype, - all, - pattern: pattern, - criteria: criteria, - ).length; + final query = criteria ?? + SimilarMatchCriteria( + pattern: (pattern ?? prototype.merchantKey).trim(), + ); + if (query.pattern.trim().isEmpty) return 1; + var n = 0; + for (final tx in all) { + if (txMatchesSimilarCriteria(tx, query)) n++; + } + return n == 0 ? 1 : n; } List similarTo( @@ -630,23 +642,113 @@ class TxMetaController extends ChangeNotifier { _schedulePersist(); } + /// Recurring look-ahead templates (manual + auto from marked txs). + List get recurringTemplates { + final list = _recurringTemplates.values.toList(growable: false); + list.sort((a, b) { + final day = a.dayOfMonth.compareTo(b.dayOfMonth); + if (day != 0) return day; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + return list; + } + + RecurringTemplate? recurringTemplateById(String id) => + _recurringTemplates[id]; + + void upsertRecurringTemplate(RecurringTemplate template) { + if (template.id.isEmpty) return; + _recurringTemplates[template.id] = template; + _schedulePersist(invalidateLabels: false); + } + + void removeRecurringTemplate(String id) { + if (!_recurringTemplates.containsKey(id)) return; + _recurringTemplates.remove(id); + _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); + } + Future setRecurring(BankTransaction tx, bool recurring) async { final current = metaFor(tx); - _byId = { - ..._byId, - tx.id: current.copyWith(recurring: recurring), - }; + _byId[tx.id] = current.copyWith(recurring: recurring); final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); - _exactTemplates = { - ..._exactTemplates, - tx.exactKey: TxExactTemplate( - notes: existing.notes.isNotEmpty ? existing.notes : current.notes, - recurring: recurring, - internalTransfer: - existing.internalTransfer || current.internalTransfer, - ), - }; - _schedulePersist(); + _exactTemplates[tx.exactKey] = TxExactTemplate( + notes: existing.notes.isNotEmpty ? existing.notes : current.notes, + 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); } Future setInternalTransfer( @@ -654,20 +756,14 @@ class TxMetaController extends ChangeNotifier { bool internalTransfer, ) async { final current = metaFor(tx); - _byId = { - ..._byId, - tx.id: current.copyWith(internalTransfer: internalTransfer), - }; + _byId[tx.id] = current.copyWith(internalTransfer: internalTransfer); final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); - _exactTemplates = { - ..._exactTemplates, - tx.exactKey: TxExactTemplate( - notes: existing.notes.isNotEmpty ? existing.notes : current.notes, - recurring: existing.recurring || current.recurring, - internalTransfer: internalTransfer, - ), - }; - _schedulePersist(); + _exactTemplates[tx.exactKey] = TxExactTemplate( + notes: existing.notes.isNotEmpty ? existing.notes : current.notes, + recurring: existing.recurring || current.recurring, + internalTransfer: internalTransfer, + ); + _schedulePersist(invalidateLabels: false); } Future setNotes( @@ -824,27 +920,35 @@ class TxMetaController extends ChangeNotifier { return list; } - /// UI-first: notify on the next frame so callers can paint a busy state first, - /// then write SharedPreferences in the background. - void _schedulePersist() { - _invalidateLabelCache(); - final binding = WidgetsBinding.instance; - binding.addPostFrameCallback((_) { - notifyListeners(); - }); - // Ensure a frame is scheduled even if nothing else is dirty yet. - binding.scheduleFrame(); - unawaited(_writeStore()); + int _persistGeneration = 0; + int _storeRevision = 0; + + /// Bumps on every local mutation — cheap cache key for look-ahead etc. + int get storeRevision => _storeRevision; + + /// Notify immediately; persist after the frame so toggles paint first. + void _schedulePersist({bool invalidateLabels = true}) { + if (invalidateLabels) _invalidateLabelCache(); + _storeRevision++; + notifyListeners(); + final gen = ++_persistGeneration; + unawaited(_writeStoreDeferred(gen)); + } + + Future _writeStoreDeferred(int gen) async { + await WidgetsBinding.instance.endOfFrame; + // Coalesce rapid edits — only the latest snapshot is written. + if (gen != _persistGeneration) return; + await _writeStore(); } Future _writeStore({bool notifySync = true}) async { - // Let the busy indicator / label checkbox paint before JSON work. - await Future.delayed(Duration.zero); final snapshot = TxMetaStoreData( byId: _byId, labelsByMerchant: _labelsByMerchant, notesByMerchant: _notesByMerchant, exactTemplates: _exactTemplates, + recurringTemplates: _recurringTemplates, customLabels: [ for (final label in _customLabels) label.copyWith( diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 73868cf..5f48424 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -17,23 +17,11 @@ class AccountScreen extends StatelessWidget { @override Widget build(BuildContext context) { final auth = context.watch(); - final sync = context.watch(); final meta = context.watch(); final bank = context.watch(); final theme = Theme.of(context); final colors = theme.colorScheme; - final lastSync = sync.lastSyncedAt; - final syncSubtitle = sync.isSyncing - ? (sync.statusDetail.isEmpty - ? 'Syncing… ${(sync.syncProgress * 100).round()}%' - : '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%') - : sync.lastError != null - ? 'Last error: ${sync.lastError}' - : lastSync == null - ? 'Not synced yet' - : 'Last sync ${_formatTime(lastSync)}'; - final categorized = meta.labeledTransactionCount(bank.transactions); final perTxAnnotations = meta.syncableAnnotationCount; final viaMerchantOnly = meta.merchantOnlyLabeledCount(bank.transactions); @@ -83,41 +71,7 @@ class AccountScreen extends StatelessWidget { subtitle: Text(kPocketBaseUrl), ), const Divider(height: 1), - ListTile( - leading: sync.isSyncing - ? SizedBox( - width: 24, - height: 24, - child: Padding( - padding: const EdgeInsets.all(2), - child: CircularProgressIndicator( - strokeWidth: 2, - value: sync.syncProgress <= 0 - ? null - : sync.syncProgress.clamp(0.02, 1.0), - ), - ), - ) - : Icon( - sync.lastError == null - ? Icons.sync - : Icons.sync_problem_outlined, - ), - title: const Text('Cloud sync'), - subtitle: Text(syncSubtitle), - trailing: TextButton( - onPressed: sync.isSyncing ? null : () => sync.syncNow(), - child: const Text('Sync now'), - ), - ), - if (sync.lastSyncSummary.isNotEmpty) ...[ - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.fact_check_outlined), - title: const Text('Last sync detail'), - subtitle: Text(sync.lastSyncSummary), - ), - ], + const _CloudSyncTile(), const Divider(height: 1), ListTile( leading: const Icon(Icons.phone_android_outlined), @@ -150,6 +104,72 @@ class AccountScreen extends StatelessWidget { ), ); } +} + +/// Isolated so sync progress ticks do not rebuild account stats. +class _CloudSyncTile extends StatelessWidget { + const _CloudSyncTile(); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: context.read(), + builder: (context, _) { + final sync = context.read(); + final lastSync = sync.lastSyncedAt; + final syncSubtitle = sync.isSyncing + ? (sync.statusDetail.isEmpty + ? 'Syncing… ${(sync.syncProgress * 100).round()}%' + : '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%') + : sync.lastError != null + ? 'Last error: ${sync.lastError}' + : lastSync == null + ? 'Not synced yet' + : 'Last sync ${_formatTime(lastSync)}'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: sync.isSyncing + ? SizedBox( + width: 24, + height: 24, + child: Padding( + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + strokeWidth: 2, + value: sync.syncProgress <= 0 + ? null + : sync.syncProgress.clamp(0.02, 1.0), + ), + ), + ) + : Icon( + sync.lastError == null + ? Icons.sync + : Icons.sync_problem_outlined, + ), + title: const Text('Cloud sync'), + subtitle: Text(syncSubtitle), + trailing: TextButton( + onPressed: sync.isSyncing ? null : () => sync.syncNow(), + child: const Text('Sync now'), + ), + ), + if (sync.lastSyncSummary.isNotEmpty) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.fact_check_outlined), + title: const Text('Last sync detail'), + subtitle: Text(sync.lastSyncSummary), + ), + ], + ], + ); + }, + ); + } static String _formatTime(DateTime time) { final local = time.toLocal(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6727220..deaaf51 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -13,6 +13,7 @@ import '../utils/navigation.dart'; import '../widgets/empty_state.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/shell_tab_scope.dart'; import '../widgets/skip_build_when_covered.dart'; import 'transaction_detail_screen.dart'; import 'transactions_screen.dart'; @@ -20,8 +21,16 @@ import 'transactions_screen.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); + static const tabIndex = 0; + @override Widget build(BuildContext context) { + // Inactive IndexedStack tabs must not watch providers — otherwise every + // annotation save rebuilds Home while the user is on another tab. + if (ShellTabScope.of(context) != tabIndex) { + return const SizedBox.expand(); + } + final bank = context.watch(); return Scaffold( diff --git a/lib/screens/look_ahead_screen.dart b/lib/screens/look_ahead_screen.dart new file mode 100644 index 0000000..afb9b0e --- /dev/null +++ b/lib/screens/look_ahead_screen.dart @@ -0,0 +1,625 @@ +/// 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/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; + var _didBackfill = false; + List? _cachedItems; + int? _itemsCacheKey; + + static const _maxYearsAhead = 5; + + @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); + + DateTime get _maxMonth { + final t = _today; + return DateTime(t.year + _maxYearsAhead, t.month); + } + + /// 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); + } + + bool get _canGoBack { + if (_period == _PeriodMode.yearly) { + return _cursor.year > _today.year; + } + return monthStart(_cursor).isAfter(_minMonth); + } + + bool get _canGoForward { + if (_period == _PeriodMode.yearly) { + return _cursor.year < _today.year + _maxYearsAhead; + } + return monthStart(_cursor).isBefore(_maxMonth); + } + + void _setPeriod(_PeriodMode mode) { + 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 + _maxYearsAhead) { + _cursor = DateTime(_today.year + _maxYearsAhead); + } + } else { + final month = DateTime(_cursor.year, _today.month); + _cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month); + if (_cursor.isAfter(_maxMonth)) _cursor = _maxMonth; + } + }); + } + + void _go(int delta) { + setState(() { + if (_period == _PeriodMode.yearly) { + final next = DateTime(_cursor.year + delta); + if (next.year < _today.year || + next.year > _today.year + _maxYearsAhead) { + return; + } + _cursor = next; + } else { + final next = addMonths(monthStart(_cursor), delta); + 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 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 + ? 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; + } + } + + 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: _setPeriod, + ), + const SizedBox(height: 10), + _PeriodNavigator( + title: _periodTitle, + canGoBack: _canGoBack, + canGoForward: _canGoForward, + onBack: () => _go(-1), + onForward: () => _go(1), + 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 templates', + 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 + ? 'Mark transactions as recurring, or create a template.' + : '${items.length} template${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), + if (items.isEmpty) + const EmptyState( + icon: Icons.event_repeat_outlined, + title: 'No recurring templates', + message: + 'Mark a transaction as recurring, or tap New to base a ' + 'template on 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 template', + 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), + ), + ); + }, + ); + } +} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 1e50b59..42ab80d 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,9 +1,11 @@ -/// Host scaffold: Home + Overview. Settings lives in the app-bar gear. +/// Host scaffold: Home, Overview, Look ahead. Settings lives in the app-bar gear. library; import 'package:flutter/material.dart'; +import '../widgets/shell_tab_scope.dart'; import 'home_screen.dart'; +import 'look_ahead_screen.dart'; import 'overview_screen.dart'; class MainShell extends StatefulWidget { @@ -19,31 +21,40 @@ class _MainShellState extends State { static const _pages = [ HomeScreen(), OverviewScreen(), + LookAheadScreen(), ]; @override Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack( - index: _index, - children: _pages, - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: (index) => setState(() => _index = index), - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - NavigationDestination( - icon: Icon(Icons.insights_outlined), - selectedIcon: Icon(Icons.insights), - label: 'Overview', - ), - ], + return ShellTabScope( + index: _index, + child: Scaffold( + body: IndexedStack( + index: _index, + children: _pages, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (index) => setState(() => _index = index), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.insights_outlined), + selectedIcon: Icon(Icons.insights), + label: 'Overview', + ), + NavigationDestination( + icon: Icon(Icons.event_repeat_outlined), + selectedIcon: Icon(Icons.event_repeat), + label: 'Look ahead', + ), + ], + ), ), ); } diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 3797760..8b6f580 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -17,6 +17,7 @@ import '../widgets/empty_state.dart'; import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/shell_tab_scope.dart'; import '../widgets/skip_build_when_covered.dart'; import 'label_transactions_screen.dart'; import 'transactions_screen.dart'; @@ -31,6 +32,8 @@ const _kExpenseColor = AppTheme.expense; class OverviewScreen extends StatefulWidget { const OverviewScreen({super.key}); + static const tabIndex = 1; + @override State createState() => _OverviewScreenState(); } @@ -163,6 +166,10 @@ class _OverviewScreenState extends State { @override Widget build(BuildContext context) { + if (ShellTabScope.of(context) != OverviewScreen.tabIndex) { + return const SizedBox.expand(); + } + final bank = context.watch(); final meta = context.watch(); diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index e365cbc..245471d 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -26,11 +26,14 @@ class TransactionDetailScreen extends StatefulWidget { class _TransactionDetailScreenState extends State { late final TextEditingController _notesController; + late final TextEditingController _labelSearchController; late SimilarMatchCriteria _matchCriteria; late Set _draftLabelIds; + var _similarCount = 0; + var _similarReady = false; var _dirty = false; var _labelsDirty = false; - late bool _applyToSimilar; + var _applyToSimilar = false; var _busy = false; String? _busyMessage; @@ -40,6 +43,7 @@ class _TransactionDetailScreenState extends State { final meta = context.read(); final notes = meta.metaFor(widget.tx).notes; _notesController = TextEditingController(text: notes); + _labelSearchController = TextEditingController(); final tx = widget.tx; _matchCriteria = SimilarMatchCriteria( pattern: defaultSimilarPattern(tx.description), @@ -50,19 +54,31 @@ class _TransactionDetailScreenState extends State { : TxFlowFilter.all, ); _draftLabelIds = meta.metaFor(tx).labelIds.toSet(); + // Defer full-list similar scan so the detail route paints immediately. + WidgetsBinding.instance.addPostFrameCallback((_) => _refreshSimilarCount()); + } + + void _refreshSimilarCount() { + if (!mounted) return; final all = context.read().transactions; - final similarCount = meta.countSimilar( - tx, - all, - criteria: _matchCriteria, - ); - // Default on when there are peers — otherwise users never notice the toggle. - _applyToSimilar = similarCount > 1; + final count = context.read().countSimilar( + widget.tx, + all, + criteria: _matchCriteria, + ); + if (!mounted) return; + setState(() { + _similarCount = count; + _similarReady = true; + // Default on when there are peers — otherwise users never notice the toggle. + if (count > 1) _applyToSimilar = true; + }); } @override void dispose() { _notesController.dispose(); + _labelSearchController.dispose(); super.dispose(); } @@ -85,13 +101,10 @@ class _TransactionDetailScreenState extends State { if (result == null || !mounted) return; setState(() { _matchCriteria = result; - final count = context.read().countSimilar( - widget.tx, - all, - criteria: result, - ); - if (count > 1) _applyToSimilar = true; + _similarReady = false; + _similarCount = 0; }); + _refreshSimilarCount(); } Future _runBusy( @@ -282,17 +295,27 @@ class _TransactionDetailScreenState extends State { final tx = widget.tx; final metaController = context.watch(); final meta = metaController.metaFor(tx); - // Prefer read: bank data rarely changes while editing labels. - final all = context.read().transactions; - final similarCount = metaController.countSimilar( - tx, - all, - criteria: _matchCriteria, - ); + final similarCount = _similarCount; final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense; - final labelEntries = metaController.labelsInTreeOrder(); + final needle = _labelSearchController.text.trim().toLowerCase(); + final allLabelEntries = metaController.labelsInTreeOrder(); + final labelEntries = needle.isEmpty + ? allLabelEntries + : () { + final keep = {}; + for (final entry in allLabelEntries) { + if (!entry.label.name.toLowerCase().contains(needle)) continue; + keep.add(entry.label.id); + keep.addAll(metaController.ancestorIdsOf(entry.label.id)); + keep.addAll(metaController.descendantIdsOf(entry.label.id)); + } + return [ + for (final entry in allLabelEntries) + if (keep.contains(entry.label.id)) entry, + ]; + }(); return Scaffold( appBar: AppBar( @@ -305,275 +328,325 @@ class _TransactionDetailScreenState extends State { ), ], ), - body: ResponsiveBody( - child: ListView( - padding: const EdgeInsets.only(top: 8, bottom: 32), - children: [ - if (_busy) ...[ - const LinearProgressIndicator(minHeight: 2), - if (_busyMessage != null) - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 4), - child: Text( - _busyMessage!, - style: theme.textTheme.labelMedium?.copyWith( - color: colors.primary, - ), - ), - ), - ], - AbsorbPointer( - absorbing: _busy, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - formatMoney(tx.amount, tx.currency), - style: theme.textTheme.headlineMedium?.copyWith( - color: amountColor, - ), - ), - const SizedBox(height: 8), - Text(tx.description, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - [ - formatIsoDate(tx.date), - if (tx.pending) 'Pending', - ].join(' · '), - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 16), - Row( - children: [ - Text('Match similar', style: theme.textTheme.titleSmall), - const Spacer(), - TextButton.icon( - onPressed: _busy ? null : _editMatchCriteria, - icon: const Icon(Icons.tune, size: 18), - label: const Text('Edit'), - ), - ], - ), - const SizedBox(height: 4), - Text( - '“${_matchCriteria.pattern}”', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - secondary: const Icon(Icons.copy_all_outlined), - title: const Text('Include matched transactions'), - subtitle: Text( - similarCount <= 1 - ? 'No other matches for this pattern' - : '$similarCount matching transactions', - ), - value: _applyToSimilar && similarCount > 1, - onChanged: _busy || similarCount <= 1 - ? null - : (value) => setState(() => _applyToSimilar = value), - ), - if (_applyToSimilar && similarCount > 1) - Align( + body: Column( + children: [ + if (_busy) const LinearProgressIndicator(minHeight: 2), + if (_busy && _busyMessage != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Align( alignment: Alignment.centerLeft, - child: TextButton( - onPressed: _busy ? null : _editMatchCriteria, - child: Text('Review $similarCount matches'), - ), - ), - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - secondary: const Icon(Icons.autorenew), - title: const Text('Recurring'), - subtitle: const Text('Salary, mortgage, subscriptions…'), - value: meta.recurring, - onChanged: _busy - ? null - : (value) { - context.read().setRecurring(tx, value); - }, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - secondary: const Icon(Icons.swap_horiz), - title: const Text('Internal transfer'), - subtitle: const Text( - 'Between your own accounts — excluded from income/expense', - ), - value: meta.internalTransfer, - onChanged: _busy - ? null - : (value) { - context - .read() - .setInternalTransfer(tx, value); - }, - ), - const SizedBox(height: 16), - Row( - children: [ - Text('Labels', style: theme.textTheme.titleSmall), - const Spacer(), - TextButton( - onPressed: _busy ? null : _createLabel, - child: const Text('New'), - ), - if (_draftLabelIds.isNotEmpty) - TextButton( - onPressed: _busy ? null : _clearDraftLabels, - child: const Text('Clear'), + child: Text( + _busyMessage!, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.primary, ), - ], - ), - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colors.outlineVariant.withValues(alpha: 0.8), ), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Column( + ), + Expanded( + child: ResponsiveBody( + child: AbsorbPointer( + absorbing: _busy, + child: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 16), children: [ - for (final entry in labelEntries) ...[ - if (entry != labelEntries.first) - Divider( - height: 1, - indent: 40, - color: colors.outlineVariant.withValues(alpha: 0.45), + Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.headlineMedium?.copyWith( + color: amountColor, + ), + ), + const SizedBox(height: 8), + Text(tx.description, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + [ + formatIsoDate(tx.date), + if (tx.pending) 'Pending', + ].join(' · '), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Row( + children: [ + Text('Match similar', style: theme.textTheme.titleSmall), + const Spacer(), + TextButton.icon( + onPressed: _busy ? null : _editMatchCriteria, + icon: const Icon(Icons.tune, size: 18), + label: const Text('Edit'), ), - InkWell( - onTap: - _busy ? null : () => _toggleDraftLabel(entry.label.id), - onLongPress: - _busy ? null : () => _labelActions(entry.label), - child: Padding( - padding: EdgeInsets.fromLTRB( - 8 + entry.depth * 16.0, - 2, - 4, - 2, - ), - child: Row( - children: [ - if (entry.depth > 0) ...[ - Icon( - Icons.subdirectory_arrow_right, - size: 14, - color: colors.onSurfaceVariant, - ), - const SizedBox(width: 4), - ], - Icon( - entry.label.icon, - size: 18, - color: entry.label.color, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - entry.label.name, - style: theme.textTheme.bodyMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox( - height: 32, - width: 32, - child: Checkbox( - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - value: _draftLabelIds.contains(entry.label.id), - onChanged: _busy - ? null - : (_) => - _toggleDraftLabel(entry.label.id), - ), - ), - ], - ), + ], + ), + const SizedBox(height: 4), + Text( + '“${_matchCriteria.pattern}”', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.copy_all_outlined), + title: const Text('Include matched transactions'), + subtitle: Text( + similarCount <= 1 + ? 'No other matches for this pattern' + : '$similarCount matching transactions', + ), + value: _applyToSimilar && similarCount > 1, + onChanged: _busy || !_similarReady || similarCount <= 1 + ? null + : (value) => setState(() => _applyToSimilar = value), + ), + if (_applyToSimilar && similarCount > 1) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _busy ? null : _editMatchCriteria, + child: Text('Review $similarCount matches'), ), ), - ], + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.autorenew), + title: const Text('Recurring'), + subtitle: const Text('Salary, mortgage, subscriptions…'), + value: meta.recurring, + onChanged: _busy + ? null + : (value) { + context.read().setRecurring(tx, value); + }, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.swap_horiz), + title: const Text('Internal transfer'), + subtitle: const Text( + 'Between your own accounts — excluded from income/expense', + ), + value: meta.internalTransfer, + onChanged: _busy + ? null + : (value) { + context + .read() + .setInternalTransfer(tx, value); + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Text('Labels', style: theme.textTheme.titleSmall), + const Spacer(), + TextButton( + onPressed: _busy ? null : _createLabel, + child: const Text('New'), + ), + if (_draftLabelIds.isNotEmpty) + TextButton( + onPressed: _busy ? null : _clearDraftLabels, + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _labelSearchController, + enabled: !_busy, + decoration: InputDecoration( + isDense: true, + hintText: 'Filter labels', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: needle.isEmpty + ? null + : IconButton( + onPressed: () { + _labelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.clear, size: 18), + ), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.8), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 280, + child: labelEntries.isEmpty + ? Center( + child: Text( + needle.isEmpty + ? 'No labels yet' + : 'No labels match', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: labelEntries.length, + separatorBuilder: (_, _) => Divider( + height: 1, + indent: 40, + color: colors.outlineVariant + .withValues(alpha: 0.45), + ), + itemBuilder: (context, index) { + final entry = labelEntries[index]; + return InkWell( + onTap: _busy + ? null + : () => _toggleDraftLabel(entry.label.id), + onLongPress: _busy + ? null + : () => _labelActions(entry.label), + child: Padding( + padding: EdgeInsets.fromLTRB( + 8 + entry.depth * 16.0, + 2, + 4, + 2, + ), + child: Row( + children: [ + if (entry.depth > 0) ...[ + Icon( + Icons.subdirectory_arrow_right, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + Icon( + entry.label.icon, + size: 18, + color: entry.label.color, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + entry.label.name, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + height: 32, + width: 32, + child: Checkbox( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + value: _draftLabelIds + .contains(entry.label.id), + onChanged: _busy + ? null + : (_) => _toggleDraftLabel( + entry.label.id, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + const SizedBox(height: 24), + Text('Notes', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + TextField( + controller: _notesController, + minLines: 3, + maxLines: 6, + enabled: !_busy, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + hintText: 'Add a private note…', + ), + onChanged: (_) { + if (!_dirty) setState(() => _dirty = true); + }, + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _dirty && !_busy ? _saveNotes : null, + child: Text( + _applyToSimilar && similarCount > 1 + ? 'Save notes for $similarCount' + : 'Save notes', + ), + ), + const SizedBox(height: 16), + Text( + 'Sub-labels also select their parents. Unchecking a parent ' + 'clears its sub-labels. Long-press a row to move or delete. ' + 'Use Apply to save label changes.', + style: theme.textTheme.labelMedium, + ), ], ), ), ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: FilledButton( - onPressed: _busy || !_labelsDirty - ? null - : () => _applyLabels(toMatched: false), - child: const Text('Apply'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton.tonal( - onPressed: _busy || - !_labelsDirty || - !_applyToSimilar || - similarCount <= 1 - ? null - : () => _applyLabels(toMatched: true), - child: Text( - similarCount > 1 - ? 'Apply to matched ($similarCount)' - : 'Apply to matched', + ), + SafeArea( + top: false, + child: Material( + elevation: 3, + color: colors.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _busy || !_labelsDirty + ? null + : () => _applyLabels(toMatched: false), + child: const Text('Apply'), + ), ), - ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.tonal( + onPressed: _busy || + !_labelsDirty || + !_applyToSimilar || + similarCount <= 1 + ? null + : () => _applyLabels(toMatched: true), + child: Text( + similarCount > 1 + ? 'Apply to matched ($similarCount)' + : 'Apply to matched', + ), + ), + ), + ], ), - ], - ), - const SizedBox(height: 24), - Text('Notes', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - TextField( - controller: _notesController, - minLines: 3, - maxLines: 6, - enabled: !_busy, - textInputAction: TextInputAction.newline, - decoration: const InputDecoration( - hintText: 'Add a private note…', - ), - onChanged: (_) { - if (!_dirty) setState(() => _dirty = true); - }, - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _dirty && !_busy ? _saveNotes : null, - child: Text( - _applyToSimilar && similarCount > 1 - ? 'Save notes for $similarCount' - : 'Save notes', ), ), - const SizedBox(height: 16), - Text( - 'Sub-labels also select their parents. Unchecking a parent ' - 'clears its sub-labels. Long-press a row to move or delete. ' - 'Use Apply to save label changes.', - style: theme.textTheme.labelMedium, - ), - ], - ), - ), - ], - ), + ), + ], ), ); } diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 6e35989..9bc2bf2 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -61,6 +61,7 @@ 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 = ''; @@ -96,11 +97,15 @@ class _TransactionsScreenState extends State { bool get _hasListFilters => _labelFilters.isNotEmpty || _flowFilter != TxFlowFilter.all || - _amountRange.isActive; + _amountRange.isActive || + _recurringOnly; - bool _matchesExtraFilters(BankTransaction tx) => - txMatchesFlow(tx, _flowFilter) && - txMatchesAmountRange(tx, _amountRange); + 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; + } void _goMonth(int delta) { final next = addMonths(_month, delta); @@ -167,6 +172,7 @@ class _TransactionsScreenState extends State { amountRange: _amountRange, query: _activeQuery, monthOnly: _searchInMonthOnly, + recurringOnly: _recurringOnly, ), ); if (result == null || !mounted) return; @@ -178,6 +184,7 @@ class _TransactionsScreenState extends State { _flowFilter = result.flow; _amountRange = result.amountRange; _searchInMonthOnly = result.monthOnly; + _recurringOnly = result.recurringOnly; if (!_searchInMonthOnly) { _showCalendar = false; _selectedDay = null; @@ -339,7 +346,7 @@ class _TransactionsScreenState extends State { _activeQuery, labelIds: _labelFilters, )) - if (_matchesExtraFilters(tx)) tx, + if (_matchesExtraFilters(tx, metaController)) tx, ]; // Labels on the current result that aren't part of the active label filter. @@ -675,6 +682,15 @@ 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( diff --git a/lib/services/ing_csv_import.dart b/lib/services/ing_csv_import.dart index d2d0711..f82c631 100644 --- a/lib/services/ing_csv_import.dart +++ b/lib/services/ing_csv_import.dart @@ -47,15 +47,30 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) { int _amountCents(double amount) => (amount * 100).round(); +/// Coarse bucket so fuzzy [isSameTransaction] only compares likely peers. +String _dedupeBucket(BankTransaction tx) => + '${tx.date}|${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}'; + /// Merge [extra] into [existing], skipping duplicates. Newest date first. List mergeTransactions( List existing, List extra, ) { + if (extra.isEmpty) return existing; + final buckets = >{}; + for (final tx in existing) { + buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx); + } + final out = List.of(existing); for (final tx in extra) { - if (out.any((e) => isSameTransaction(e, tx))) continue; + final key = _dedupeBucket(tx); + final peers = buckets[key]; + if (peers != null && peers.any((e) => isSameTransaction(e, tx))) { + continue; + } out.add(tx); + buckets.putIfAbsent(key, () => []).add(tx); } out.sort((a, b) { final byDate = b.date.compareTo(a.date); @@ -70,9 +85,20 @@ List filterNewTransactions( List existing, List candidates, ) { + if (candidates.isEmpty) return const []; + if (existing.isEmpty) return List.of(candidates); + + final buckets = >{}; + for (final tx in existing) { + buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx); + } + return [ for (final tx in candidates) - if (!existing.any((e) => isSameTransaction(e, tx))) tx, + if (!(buckets[_dedupeBucket(tx)] + ?.any((e) => isSameTransaction(e, tx)) ?? + false)) + tx, ]; } diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index 287948a..a9076e9 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -52,6 +52,13 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) { existing == null ? e.value : _mergeExact(existing, e.value); } + final recurringTemplates = { + ...a.recurringTemplates, + }; + for (final e in b.recurringTemplates.entries) { + recurringTemplates.putIfAbsent(e.key, () => e.value); + } + final labelsByClientId = { for (final label in a.customLabels) label.id: label, }; @@ -66,6 +73,7 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) { labelsByMerchant: labelsByMerchant, notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, + recurringTemplates: recurringTemplates, customLabels: labelsByClientId.values.toList(growable: false), labelParents: labelParents, ); @@ -170,6 +178,7 @@ class PbSyncService { }) async { final out = []; var page = 1; + var lastReported = -1; while (true) { final result = await _withTimeout( _pb.collection(collection).getList( @@ -181,8 +190,13 @@ class PbSyncService { label: '$collection list page $page', ); out.addAll(result.items); - onPage?.call(out.length); - if (result.items.length < _listPageSize) break; + final finished = result.items.length < _listPageSize; + if (onPage != null && + (finished || out.length - lastReported >= 400)) { + lastReported = out.length; + onPage(out.length); + } + if (finished) break; page++; } return out; @@ -213,7 +227,17 @@ class PbSyncService { report(0.36); onStatus?.call('Pulling templates…'); final exactRecords = await _fullList('exact_templates', filter); - report(0.45); + report(0.42); + onStatus?.call('Pulling recurring templates…'); + List recurringRecords = const []; + try { + recurringRecords = await _fullList('recurring_templates', filter); + } catch (error) { + debugPrint( + 'PbSyncService pull: recurring_templates unavailable ($error)', + ); + } + report(0.48); final customLabels = []; final labelParents = {}; @@ -269,10 +293,35 @@ class PbSyncService { ); } + final recurringTemplates = {}; + for (final r in recurringRecords) { + final clientId = r.getStringValue('client_id'); + if (clientId.isEmpty) continue; + final sourceTx = r.getStringValue('source_tx_id'); + final labelId = r.getStringValue('label_id'); + recurringTemplates[clientId] = RecurringTemplate( + id: clientId, + name: r.getStringValue('name').isEmpty + ? 'Recurring' + : r.getStringValue('name'), + dayOfMonth: r.getIntValue('day_of_month', 1).clamp(1, 28), + currency: r.getStringValue('currency').isEmpty + ? 'EUR' + : r.getStringValue('currency'), + source: _parseTemplateSource(r.getStringValue('source')), + amountRule: _parseAmountRule(r.getStringValue('amount_rule')), + merchantKey: r.getStringValue('merchant_key'), + sourceTxId: sourceTx.isEmpty ? null : sourceTx, + labelId: labelId.isEmpty ? null : labelId, + avgCount: r.getIntValue('avg_count', 5).clamp(1, 36), + autoFromRecurring: r.getBoolValue('auto_from_recurring'), + ); + } + debugPrint( 'PbSyncService pull: labels=${labelRecords.length} ' 'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} ' - 'exact=${exactRecords.length}', + 'exact=${exactRecords.length} recurring=${recurringRecords.length}', ); return TxMetaStoreData( @@ -280,6 +329,7 @@ class PbSyncService { labelsByMerchant: labelsByMerchant, notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, + recurringTemplates: recurringTemplates, customLabels: customLabels, labelParents: labelParents, ); @@ -347,6 +397,15 @@ class PbSyncService { report(0.96); onStatus?.call('Syncing templates…'); await _syncExactTemplates(userId, filter, store); + report(0.98); + onStatus?.call('Syncing recurring templates…'); + try { + await _syncRecurringTemplates(userId, filter, store); + } catch (error) { + debugPrint( + 'PbSyncService push: recurring_templates unavailable ($error)', + ); + } report(1.0); return txSummary; } @@ -563,7 +622,11 @@ class PbSyncService { ]); done = end; onProgress?.call(done / total); - onStatus?.call('Syncing $collection ($done/$total)…'); + // Status less often than progress — SyncController already throttles, + // but fewer string allocs/churn still help. + if (done == total || done == end && (done ~/ _writeConcurrency) % 4 == 0) { + onStatus?.call('Syncing $collection ($done/$total)…'); + } } if (errors.isNotEmpty) { @@ -603,6 +666,12 @@ class PbSyncService { filter = 'user="$userId" && exact_key="${key.replaceAll('"', '\\"')}"'; } + case 'recurring_templates': + final key = body['client_id']; + if (userId != null && key is String) { + filter = + 'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"'; + } case 'transactions': final key = body['tx_id']; if (userId != null && key is String) { @@ -853,6 +922,61 @@ class PbSyncService { await _runWriteOps('exact_templates', ops); } + Future _syncRecurringTemplates( + String userId, + String filter, + TxMetaStoreData store, + ) async { + final existing = { + for (final r in await _fullList('recurring_templates', filter)) + r.getStringValue('client_id'): r, + }; + final keep = {}; + final ops = <_BatchOp>[]; + + for (final e in store.recurringTemplates.entries) { + final t = e.value; + final clientId = _clip(t.id.isNotEmpty ? t.id : e.key, 120); + if (clientId.isEmpty) continue; + keep.add(clientId); + final body = { + 'user': userId, + 'client_id': clientId, + 'name': _clip(t.name.isEmpty ? 'Recurring' : t.name, 400), + 'day_of_month': t.dayOfMonth.clamp(1, 28), + 'currency': _clip( + t.currency.isEmpty ? 'EUR' : t.currency.toUpperCase(), + 8, + ), + 'source': t.source.name, + 'amount_rule': t.amountRule.name, + 'merchant_key': _clip(t.merchantKey, 500), + 'source_tx_id': _clip(t.sourceTxId ?? '', 2000), + 'label_id': _clip(t.labelId ?? '', 80), + 'avg_count': t.avgCount.clamp(1, 36), + 'auto_from_recurring': t.autoFromRecurring, + }; + final remote = existing[clientId]; + if (remote == null) { + ops.add(_BatchOp.create(body)); + } else if (!_recurringUnchanged(remote, t)) { + ops.add(_BatchOp.update(remote.id, body)); + } + } + + for (final e in existing.entries) { + if (!keep.contains(e.key)) { + ops.add(_BatchOp.delete(e.value.id)); + } + } + + debugPrint( + 'PbSyncService recurring_templates: remote=${existing.length} ' + 'ops=${ops.length}', + ); + await _runWriteOps('recurring_templates', ops); + } + bool _labelUnchanged(RecordModel remote, Map body) { return remote.getStringValue('name') == body['name'] && remote.getIntValue('color') == body['color'] && @@ -882,6 +1006,34 @@ class PbSyncService { remote.getBoolValue('recurring') == t.recurring && remote.getBoolValue('internal_transfer') == t.internalTransfer; } + + bool _recurringUnchanged(RecordModel remote, RecurringTemplate t) { + return remote.getStringValue('name') == t.name && + remote.getIntValue('day_of_month') == t.dayOfMonth && + remote.getStringValue('currency') == t.currency && + remote.getStringValue('source') == t.source.name && + remote.getStringValue('amount_rule') == t.amountRule.name && + remote.getStringValue('merchant_key') == t.merchantKey && + remote.getStringValue('source_tx_id') == (t.sourceTxId ?? '') && + remote.getStringValue('label_id') == (t.labelId ?? '') && + remote.getIntValue('avg_count', 5) == t.avgCount && + remote.getBoolValue('auto_from_recurring') == t.autoFromRecurring; + } +} + +RecurringTemplateSource _parseTemplateSource(String raw) { + return switch (raw) { + 'label' => RecurringTemplateSource.label, + _ => RecurringTemplateSource.transaction, + }; +} + +RecurringAmountRule _parseAmountRule(String raw) { + return switch (raw) { + 'avgLastTxs' => RecurringAmountRule.avgLastTxs, + 'labelAvgMonths' => RecurringAmountRule.labelAvgMonths, + _ => RecurringAmountRule.lastValue, + }; } List _jsonStringList(dynamic raw) { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 52928db..24dbd47 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -3,6 +3,7 @@ library; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/bank.dart'; @@ -16,6 +17,7 @@ class TxMetaStoreData { this.labelsByMerchant = const {}, this.notesByMerchant = const {}, this.exactTemplates = const {}, + this.recurringTemplates = const {}, this.customLabels = const [], this.labelParents = const {}, }); @@ -24,6 +26,7 @@ class TxMetaStoreData { final Map> labelsByMerchant; final Map notesByMerchant; final Map exactTemplates; + final Map recurringTemplates; final List customLabels; /// childLabelId → parentLabelId (roots omitted). @@ -147,8 +150,9 @@ class StorageService { } } - Future saveBankCache(BankCache cache) { - return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson())); + Future saveBankCache(BankCache cache) async { + final json = await compute(jsonEncode, cache.toJson()); + await _prefs.setString(StorageKeys.bankCache, json); } Future clearBankCache() { @@ -170,7 +174,7 @@ class StorageService { } } - Future saveImportedTransactions(List txs) { + Future saveImportedTransactions(List txs) async { final encoded = [ for (final tx in txs) { @@ -181,10 +185,8 @@ class StorageService { 'pending': tx.pending, }, ]; - return _prefs.setString( - StorageKeys.importedTransactions, - jsonEncode(encoded), - ); + final json = await compute(jsonEncode, encoded); + await _prefs.setString(StorageKeys.importedTransactions, json); } Future clearImportedTransactions() { @@ -225,7 +227,7 @@ class StorageService { } } - Future saveTxMetaStore(TxMetaStoreData store) { + Future saveTxMetaStore(TxMetaStoreData store) async { final encoded = { 'byId': { for (final e in store.byId.entries) e.key: e.value.toJson(), @@ -247,12 +249,18 @@ class StorageService { 'exactTemplates': { for (final e in store.exactTemplates.entries) e.key: e.value.toJson(), }, + 'recurringTemplates': { + for (final e in store.recurringTemplates.entries) + e.key: e.value.toJson(), + }, 'customLabels': [ for (final label in store.customLabels) label.toJson(), ], 'labelParents': store.labelParents, }; - return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded)); + // jsonEncode of large annotation maps is CPU-heavy — keep it off the UI isolate. + final json = await compute(jsonEncode, encoded); + await _prefs.setString(StorageKeys.txMeta, json); } TxMetaStoreData _decodeStore(String raw) { @@ -326,6 +334,19 @@ class StorageService { }); } + final recurringTemplates = {}; + final recurringRaw = root['recurringTemplates']; + if (recurringRaw is Map) { + recurringRaw.forEach((key, value) { + if (key is String && value is Map) { + final tpl = + RecurringTemplate.fromJson(Map.from(value)); + if (tpl.id.isEmpty) return; + recurringTemplates[tpl.id.isNotEmpty ? tpl.id : key] = tpl; + } + }); + } + final customLabels = []; final customs = root['customLabels']; if (customs is List) { @@ -365,6 +386,7 @@ class StorageService { labelsByMerchant: labelsByMerchant, notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, + recurringTemplates: recurringTemplates, customLabels: customLabels, labelParents: labelParents, ); diff --git a/lib/utils/look_ahead.dart b/lib/utils/look_ahead.dart index a0ea2f3..58411ce 100644 --- a/lib/utils/look_ahead.dart +++ b/lib/utils/look_ahead.dart @@ -1,4 +1,4 @@ -/// Project future balances from recurring transactions. +/// Project future balances from recurring templates. library; import '../models/bank.dart'; @@ -8,54 +8,220 @@ import 'tx_calendar.dart'; /// One monthly recurring item used for look-ahead forecasts. class RecurringItem { const RecurringItem({ + required this.templateId, required this.description, required this.amount, required this.currency, required this.dayOfMonth, + this.ruleSummary = '', + this.sourceTxId, this.labelIds = const [], }); + final String templateId; final String description; final double amount; final String currency; final int dayOfMonth; + final String ruleSummary; + final String? sourceTxId; final List labelIds; } -/// Build unique recurring templates from annotated transactions. -List recurringItemsFrom({ +/// Build projection items from first-class [templates]. +List recurringItemsFromTemplates({ + required Iterable templates, required List transactions, - required TxMeta Function(BankTransaction tx) metaFor, + required bool Function(BankTransaction tx) isInternalTransfer, + required List Function(BankTransaction tx) labelIdsFor, + String Function(String? labelId)? labelName, }) { - final latestByExact = {}; - for (final tx in transactions) { - final meta = metaFor(tx); - if (!meta.recurring) continue; - final existing = latestByExact[tx.exactKey]; - if (existing == null) { - latestByExact[tx.exactKey] = tx; - continue; - } - final existingDate = DateTime.tryParse(existing.date); - final nextDate = DateTime.tryParse(tx.date); - if (existingDate == null || - (nextDate != null && nextDate.isAfter(existingDate))) { - latestByExact[tx.exactKey] = tx; - } - } - return [ - for (final tx in latestByExact.values) + for (final tpl in templates) RecurringItem( - description: tx.description, - amount: tx.amount, - currency: tx.currency, - dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28), - labelIds: List.of(metaFor(tx).labelIds), + templateId: tpl.id, + description: tpl.name, + amount: resolveRecurringAmount( + template: tpl, + transactions: transactions, + isInternalTransfer: isInternalTransfer, + labelIdsFor: labelIdsFor, + ), + currency: tpl.currency, + dayOfMonth: tpl.dayOfMonth.clamp(1, 28), + ruleSummary: recurringRuleSummary( + template: tpl, + labelName: labelName, + ), + sourceTxId: tpl.sourceTxId, + labelIds: [ + if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!, + ], ), ]; } +String recurringRuleSummary({ + required RecurringTemplate template, + String Function(String? labelId)? labelName, +}) { + switch (template.amountRule) { + case RecurringAmountRule.lastValue: + return 'Last value'; + case RecurringAmountRule.avgLastTxs: + return 'Avg last ${template.avgCount} matching'; + case RecurringAmountRule.labelAvgMonths: + final name = labelName?.call(template.labelId) ?? 'label'; + return 'Avg ${template.avgCount} mo · $name'; + } +} + +/// Resolve projected amount for [template] from bank history. +double resolveRecurringAmount({ + required RecurringTemplate template, + required List transactions, + required bool Function(BankTransaction tx) isInternalTransfer, + required List Function(BankTransaction tx) labelIdsFor, +}) { + switch (template.amountRule) { + case RecurringAmountRule.lastValue: + return _lastMerchantAmount( + template: template, + transactions: transactions, + ) ?? + 0; + case RecurringAmountRule.avgLastTxs: + return avgLastMatchingTxs( + count: template.avgCount, + transactions: transactions, + merchantKey: template.merchantKey, + sourceTxId: template.sourceTxId, + ); + case RecurringAmountRule.labelAvgMonths: + final labelId = template.labelId; + if (labelId == null || labelId.isEmpty) return 0; + return avgLabelMonths( + labelId: labelId, + months: template.avgCount, + transactions: transactions, + isInternalTransfer: isInternalTransfer, + labelIdsFor: labelIdsFor, + ); + } +} + +double? _lastMerchantAmount({ + required RecurringTemplate template, + required List transactions, +}) { + if (template.sourceTxId != null) { + for (final tx in transactions) { + if (tx.id == template.sourceTxId) return tx.amount; + } + } + final key = template.merchantKey; + if (key.isEmpty) return null; + BankTransaction? latest; + DateTime? latestDate; + for (final tx in transactions) { + if (tx.merchantKey != key) continue; + final date = DateTime.tryParse(tx.date); + if (date == null) continue; + if (latest == null || date.isAfter(latestDate!)) { + latest = tx; + latestDate = date; + } + } + return latest?.amount; +} + +/// Average of the last [count] txs that match the selected source +/// (same similar-pattern + flow as apply-to-similar). +double avgLastMatchingTxs({ + required int count, + required List transactions, + String merchantKey = '', + String? sourceTxId, +}) { + final n = count.clamp(1, 36); + BankTransaction? prototype; + if (sourceTxId != null && sourceTxId.isNotEmpty) { + for (final tx in transactions) { + if (tx.id == sourceTxId) { + prototype = tx; + break; + } + } + } + if (prototype == null && merchantKey.isNotEmpty) { + DateTime? latestDate; + for (final tx in transactions) { + if (tx.merchantKey != merchantKey) continue; + final date = DateTime.tryParse(tx.date); + if (date == null) continue; + if (prototype == null || date.isAfter(latestDate!)) { + prototype = tx; + latestDate = date; + } + } + } + if (prototype == null) return 0; + + final criteria = SimilarMatchCriteria( + pattern: defaultSimilarPattern(prototype.description), + flow: prototype.amount < 0 + ? TxFlowFilter.expense + : prototype.amount > 0 + ? TxFlowFilter.income + : TxFlowFilter.all, + ); + + final matches = <({BankTransaction tx, DateTime date})>[]; + for (final tx in transactions) { + if (!txMatchesSimilarCriteria(tx, criteria)) continue; + final date = DateTime.tryParse(tx.date); + if (date == null) continue; + matches.add((tx: tx, date: date)); + } + if (matches.isEmpty) return prototype.amount; + matches.sort((a, b) => b.date.compareTo(a.date)); + final take = matches.take(n).toList(growable: false); + final sum = take.fold(0, (s, e) => s + e.tx.amount); + return sum / take.length; +} + +/// Average monthly signed total for [labelId] over the last [months] completed months. +double avgLabelMonths({ + required String labelId, + required int months, + required List transactions, + required bool Function(BankTransaction tx) isInternalTransfer, + required List Function(BankTransaction tx) labelIdsFor, + DateTime? now, +}) { + final n = months.clamp(1, 36); + final today = dateOnly(now ?? DateTime.now()); + final currentMonth = monthStart(today); + final totals = {}; + for (var i = 1; i <= n; i++) { + totals[addMonths(currentMonth, -i)] = 0; + } + + for (final tx in transactions) { + if (isInternalTransfer(tx)) continue; + final date = DateTime.tryParse(tx.date); + if (date == null) continue; + final key = monthStart(date); + if (!totals.containsKey(key)) continue; + final ids = labelIdsFor(tx); + if (!ids.contains(labelId)) continue; + totals[key] = (totals[key] ?? 0) + tx.amount; + } + + final sum = totals.values.fold(0, (s, v) => s + v); + return sum / n; +} + /// Occurrences of [items] with dates in `(from, to]` (exclusive start, inclusive end). List<({RecurringItem item, DateTime date})> projectedOccurrences({ required List items, diff --git a/lib/utils/navigation.dart b/lib/utils/navigation.dart index 37766a1..67c1434 100644 --- a/lib/utils/navigation.dart +++ b/lib/utils/navigation.dart @@ -10,120 +10,150 @@ import '../screens/account_screen.dart'; import '../screens/login_screen.dart'; import '../screens/settings_screen.dart'; -Widget authAppBarAction(BuildContext context) { - final auth = context.watch(); - final sync = context.watch(); - final showRing = sync.showProgressRing; - final progress = sync.syncProgress; - final success = sync.progressSuccess; - final syncActive = sync.isSyncing || showRing; +/// Account / sign-in control for app bars. +/// +/// Sync progress is subscribed in a nested widget so progress ticks do not +/// rebuild the whole screen (Overview charts, Look ahead, etc.). +Widget authAppBarAction(BuildContext context) => const _AuthAppBarAction(); - if (auth.isLoggedIn) { - final ringColor = success - ? const Color(0xFF16A34A) - : Theme.of(context).colorScheme.primary; +class _AuthAppBarAction extends StatelessWidget { + const _AuthAppBarAction(); - return PopupMenuButton<_AccountMenuAction>( - tooltip: sync.isSyncing - ? 'Syncing… ${(progress * 100).round()}%' - : auth.displayName, - offset: const Offset(0, 48), - onSelected: (action) async { - switch (action) { - case _AccountMenuAction.account: - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AccountScreen()), - ); - case _AccountMenuAction.logout: - await auth.logout(); - } - }, - itemBuilder: (context) { - final email = auth.email; - // Capture whether sync was active when the menu opened; the tile - // itself keeps listening so the % can update while the menu is open. - final showSyncRow = syncActive; - return [ - if (email != null) - PopupMenuItem<_AccountMenuAction>( - enabled: false, - child: Text( - email, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - if (showSyncRow) - const PopupMenuItem<_AccountMenuAction>( - enabled: false, - child: _SyncProgressMenuTile(), - ), - const PopupMenuItem<_AccountMenuAction>( - value: _AccountMenuAction.account, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.person_outline), - title: Text('Account'), - ), - ), - const PopupMenuItem<_AccountMenuAction>( - value: _AccountMenuAction.logout, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.logout), - title: Text('Sign out'), - ), - ), - ]; - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SizedBox( - width: 32, - height: 32, - child: Stack( - alignment: Alignment.center, - children: [ - CircleAvatar( - radius: 16, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Text( - auth.initials, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: - Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - if (showRing) - SizedBox( - width: 32, - height: 32, - child: CircularProgressIndicator( - value: progress <= 0 ? null : progress.clamp(0.02, 1.0), - strokeWidth: 2.5, - color: ringColor, - backgroundColor: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.45), + @override + Widget build(BuildContext context) { + final auth = context.watch(); + + if (!auth.isLoggedIn) { + return IconButton( + tooltip: 'Sign in', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + }, + icon: const Icon(Icons.person_outline), + ); + } + + return _SyncAwareAccountMenu(auth: auth); + } +} + +class _SyncAwareAccountMenu extends StatelessWidget { + const _SyncAwareAccountMenu({required this.auth}); + + final AuthController auth; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: context.read(), + builder: (context, _) { + final sync = context.read(); + final showRing = sync.showProgressRing; + final progress = sync.syncProgress; + final success = sync.progressSuccess; + final syncActive = sync.isSyncing || showRing; + final ringColor = success + ? const Color(0xFF16A34A) + : Theme.of(context).colorScheme.primary; + + return PopupMenuButton<_AccountMenuAction>( + tooltip: sync.isSyncing + ? 'Syncing… ${(progress * 100).round()}%' + : auth.displayName, + offset: const Offset(0, 48), + onSelected: (action) async { + switch (action) { + case _AccountMenuAction.account: + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AccountScreen(), + ), + ); + case _AccountMenuAction.logout: + await auth.logout(); + } + }, + itemBuilder: (context) { + final email = auth.email; + final showSyncRow = syncActive; + return [ + if (email != null) + PopupMenuItem<_AccountMenuAction>( + enabled: false, + child: Text( + email, + style: Theme.of(context).textTheme.bodySmall, ), ), - ], + if (showSyncRow) + const PopupMenuItem<_AccountMenuAction>( + enabled: false, + child: _SyncProgressMenuTile(), + ), + const PopupMenuItem<_AccountMenuAction>( + value: _AccountMenuAction.account, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.person_outline), + title: Text('Account'), + ), + ), + const PopupMenuItem<_AccountMenuAction>( + value: _AccountMenuAction.logout, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.logout), + title: Text('Sign out'), + ), + ), + ]; + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 32, + height: 32, + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + child: Text( + auth.initials, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + if (showRing) + SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + value: + progress <= 0 ? null : progress.clamp(0.02, 1.0), + strokeWidth: 2.5, + color: ringColor, + backgroundColor: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.45), + ), + ), + ], + ), + ), ), - ), - ), + ); + }, ); } - - return IconButton( - tooltip: 'Sign in', - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LoginScreen()), - ); - }, - icon: const Icon(Icons.person_outline), - ); } /// Live sync status row inside the account popup (watches [SyncController]). @@ -132,46 +162,49 @@ class _SyncProgressMenuTile extends StatelessWidget { @override Widget build(BuildContext context) { - final sync = context.watch(); - final theme = Theme.of(context); - final colors = theme.colorScheme; - final pct = (sync.syncProgress * 100).round().clamp(0, 100); - final detail = sync.statusDetail.trim(); + return ListenableBuilder( + listenable: context.read(), + builder: (context, _) { + final sync = context.read(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final pct = (sync.syncProgress * 100).round().clamp(0, 100); + final detail = sync.statusDetail.trim(); - if (sync.progressSuccess) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.check_circle_outline, color: colors.primary), - title: Text( - 'Sync complete', - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurface, + if (sync.progressSuccess) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.check_circle_outline, color: colors.primary), + title: Text( + 'Sync complete', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + ), + ), + ); + } + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: sync.syncProgress <= 0 + ? null + : sync.syncProgress.clamp(0.02, 1.0), + strokeWidth: 2.5, + ), ), - ), - ); - } - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: sync.syncProgress <= 0 - ? null - : sync.syncProgress.clamp(0.02, 1.0), - strokeWidth: 2.5, - ), - ), - title: Text( - detail.isEmpty ? 'Sync in progress ($pct%)' : detail, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurface, - ), - ), - subtitle: detail.isEmpty - ? null - : Text('Sync in progress ($pct%)'), + title: Text( + detail.isEmpty ? 'Sync in progress ($pct%)' : detail, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + ), + ), + subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'), + ); + }, ); } } diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index 2964b6e..b61ae5f 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -19,6 +19,7 @@ class TransactionFilterSelection { this.amountRange = const TxAmountRange(), this.query = '', this.monthOnly = false, + this.recurringOnly = false, }); final Set labelIds; @@ -29,10 +30,14 @@ 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; + query.trim().isNotEmpty || + recurringOnly; } /// How label series are drawn on the overview chart. @@ -78,23 +83,35 @@ Future showLabelFilterDialog( bool includeInvertChart = false, bool initialInvertChart = false, }) { - return showDialog( + return showModalBottomSheet( context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, builder: (dialogContext) { - return _SimpleLabelFilterDialog( - available: available, - initiallySelected: initiallySelected, - title: title, - subtitle: subtitle, - includeUncategorized: includeUncategorized, - allowMove: allowMove, - allowCreate: allowCreate, - includeFlow: includeFlow, - initialFlow: initialFlow, - includeBarStyle: includeBarStyle, - initialBarStyle: initialBarStyle, - includeInvertChart: includeInvertChart, - initialInvertChart: initialInvertChart, + final media = MediaQuery.of(dialogContext); + final maxHeight = + (media.size.height - media.viewInsets.bottom) * 0.92; + return Padding( + padding: EdgeInsets.only(bottom: media.viewInsets.bottom), + child: SizedBox( + height: maxHeight.clamp(320.0, media.size.height), + child: _SimpleLabelFilterDialog( + available: available, + initiallySelected: initiallySelected, + title: title, + subtitle: subtitle, + includeUncategorized: includeUncategorized, + allowMove: allowMove, + allowCreate: allowCreate, + includeFlow: includeFlow, + initialFlow: initialFlow, + includeBarStyle: includeBarStyle, + initialBarStyle: initialBarStyle, + includeInvertChart: includeInvertChart, + initialInvertChart: initialInvertChart, + ), + ), ); }, ); @@ -241,10 +258,6 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { final showUncategorized = widget.includeUncategorized && (needle.isEmpty || kUncategorizedFilterLabel.name.toLowerCase().contains(needle)); - final media = MediaQuery.of(context); - final maxContentHeight = - ((media.size.height - media.viewInsets.bottom) * 0.72) - .clamp(320.0, 720.0); final visibleIds = [ for (final e in entries) e.label.id, @@ -255,204 +268,205 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { visibleIds.isNotEmpty && selectedVisible == visibleIds.length; final noneVisibleSelected = selectedVisible == 0; - return AlertDialog( - title: Text(widget.title), - content: SizedBox( - width: double.maxFinite, - height: maxContentHeight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - widget.subtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - if (widget.includeFlow) ...[ - const SizedBox(height: 12), - Text( - 'Income / expense', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - _RectToggleBar( - value: _flow, - onChanged: (value) => setState(() => _flow = value), - options: const [ - _RectToggleOption( - value: TxFlowFilter.all, - label: 'All', - ), - _RectToggleOption( - value: TxFlowFilter.income, - label: 'In', - icon: Icons.south_west, - ), - _RectToggleOption( - value: TxFlowFilter.expense, - label: 'Out', - icon: Icons.north_east, - ), - ], - ), - if (widget.includeInvertChart && - _flow == TxFlowFilter.all) ...[ - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: const Text('Invert chart'), - subtitle: const Text( - 'Expense view: spending up, income/refunds down', - ), - value: _invertChart, - onChanged: (value) => - setState(() => _invertChart = value), - ), - ], - ], - if (widget.includeBarStyle) ...[ - const SizedBox(height: 12), - Text( - 'Bar layout', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - _RectToggleBar( - value: _barStyle, - onChanged: (value) => - setState(() => _barStyle = value), - options: const [ - _RectToggleOption( - value: LabelChartBarStyle.parallel, - label: 'Parallel', - icon: Icons.view_week_outlined, - ), - _RectToggleOption( - value: LabelChartBarStyle.stacked, - label: 'Stacked', - icon: Icons.layers_outlined, - ), - ], - ), - ], - if (widget.allowCreate) ...[ - const SizedBox(height: 10), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _createLabel, - icon: const Icon(Icons.add, size: 18), - label: const Text('New label'), - ), - ), - ], - ], + void apply() { + Navigator.pop( + context, + LabelFilterSelection( + labelIds: Set.of(_selected), + flow: widget.includeFlow ? _flow : TxFlowFilter.all, + barStyle: widget.includeBarStyle + ? _barStyle + : LabelChartBarStyle.parallel, + invertExpensePerspective: widget.includeInvertChart && + _flow == TxFlowFilter.all && + _invertChart, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + widget.subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, ), ), - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: TextField( - controller: _labelSearchController, - decoration: InputDecoration( - isDense: true, - hintText: 'Filter labels', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: needle.isEmpty - ? null - : IconButton( - onPressed: () { - _labelSearchController.clear(); - setState(() {}); - }, - icon: const Icon(Icons.clear, size: 18), - ), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 8), + children: [ + if (widget.includeFlow) ...[ + Text( + 'Income / expense', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _RectToggleBar( + value: _flow, + onChanged: (value) => setState(() => _flow = value), + options: const [ + _RectToggleOption( + value: TxFlowFilter.all, + label: 'All', ), - onChanged: (_) => setState(() {}), - ), + _RectToggleOption( + value: TxFlowFilter.income, + label: 'In', + icon: Icons.south_west, + ), + _RectToggleOption( + value: TxFlowFilter.expense, + label: 'Out', + icon: Icons.north_east, + ), + ], ), - Tooltip( - message: allVisibleSelected ? 'Deselect all' : 'Select all', - child: Checkbox( - tristate: true, - value: allVisibleSelected - ? true - : noneVisibleSelected - ? false - : null, - onChanged: visibleIds.isEmpty - ? null - : (_) { - setState(() { - if (allVisibleSelected) { - _selected.removeAll(visibleIds); - } else { - _selected.addAll(visibleIds); - } - }); - }, + if (widget.includeInvertChart && + _flow == TxFlowFilter.all) ...[ + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Invert chart'), + subtitle: const Text( + 'Expense view: spending up, income/refunds down', + ), + value: _invertChart, + onChanged: (value) => setState(() => _invertChart = value), ), - ), + ], + const SizedBox(height: 14), ], - ), - const SizedBox(height: 8), - Expanded( - child: _LabelChecklist( + if (widget.includeBarStyle) ...[ + Text( + 'Bar layout', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _RectToggleBar( + value: _barStyle, + onChanged: (value) => setState(() => _barStyle = value), + options: const [ + _RectToggleOption( + value: LabelChartBarStyle.parallel, + label: 'Parallel', + icon: Icons.view_week_outlined, + ), + _RectToggleOption( + value: LabelChartBarStyle.stacked, + label: 'Stacked', + icon: Icons.layers_outlined, + ), + ], + ), + const SizedBox(height: 14), + ], + if (widget.allowCreate) ...[ + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _createLabel, + icon: const Icon(Icons.add, size: 18), + label: const Text('New label'), + ), + ), + const SizedBox(height: 8), + ], + Text( + 'Labels', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _labelSearchController, + decoration: InputDecoration( + isDense: true, + hintText: 'Filter labels', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: needle.isEmpty + ? null + : IconButton( + onPressed: () { + _labelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.clear, size: 18), + ), + ), + onChanged: (_) => setState(() {}), + ), + ), + Tooltip( + message: + allVisibleSelected ? 'Deselect all' : 'Select all', + child: Checkbox( + tristate: true, + value: allVisibleSelected + ? true + : noneVisibleSelected + ? false + : null, + onChanged: visibleIds.isEmpty + ? null + : (_) { + setState(() { + if (allVisibleSelected) { + _selected.removeAll(visibleIds); + } else { + _selected.addAll(visibleIds); + } + }); + }, + ), + ), + ], + ), + const SizedBox(height: 8), + _LabelChecklist( entries: entries, showUncategorized: showUncategorized, selected: _selected, onToggle: _toggle, allowMove: widget.allowMove, + shrinkWrap: true, ), - ), - ], + ], + ), ), - ), - actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - actions: [ - SizedBox( - width: double.maxFinite, - child: Row( + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), + FilledButton( + onPressed: apply, + child: const Text('Apply'), ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: FilledButton( - onPressed: () => Navigator.pop( - context, - LabelFilterSelection( - labelIds: Set.of(_selected), - flow: widget.includeFlow ? _flow : TxFlowFilter.all, - barStyle: widget.includeBarStyle - ? _barStyle - : LabelChartBarStyle.parallel, - invertExpensePerspective: widget.includeInvertChart && - _flow == TxFlowFilter.all && - _invertChart, - ), - ), - child: const Text('Apply'), - ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), ), ], ), @@ -488,6 +502,7 @@ 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; @@ -501,6 +516,7 @@ 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(); @@ -586,6 +602,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { amountRange: _amountResult, query: _queryController.text.trim(), monthOnly: _monthOnly, + recurringOnly: _recurringOnly, ), ); } @@ -701,6 +718,24 @@ 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 new file mode 100644 index 0000000..bee6368 --- /dev/null +++ b/lib/widgets/recurring_template_sheet.dart @@ -0,0 +1,532 @@ +/// Create / edit look-ahead recurring templates. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/bank.dart'; +import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import '../utils/helpers.dart'; +import '../utils/look_ahead.dart'; + +Future showCreateRecurringTemplateSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => const _RecurringTemplateSheet(), + ); +} + +Future showEditRecurringTemplateSheet( + BuildContext context, { + required RecurringTemplate template, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _RecurringTemplateSheet(existing: template), + ); +} + +class _RecurringTemplateSheet extends StatefulWidget { + const _RecurringTemplateSheet({this.existing}); + + final RecurringTemplate? existing; + + @override + State<_RecurringTemplateSheet> createState() => + _RecurringTemplateSheetState(); +} + +class _RecurringTemplateSheetState extends State<_RecurringTemplateSheet> { + late RecurringTemplateSource _source; + late RecurringAmountRule _rule; + late final TextEditingController _nameController; + late final TextEditingController _dayController; + late final TextEditingController _avgController; + BankTransaction? _pickedTx; + String? _labelId; + String _currency = 'EUR'; + + @override + void initState() { + super.initState(); + final existing = widget.existing; + _source = existing?.source ?? RecurringTemplateSource.transaction; + _rule = existing?.amountRule ?? RecurringAmountRule.lastValue; + _nameController = TextEditingController(text: existing?.name ?? ''); + _dayController = TextEditingController( + text: '${existing?.dayOfMonth ?? 1}', + ); + _avgController = TextEditingController( + text: '${existing?.avgCount ?? 5}', + ); + _labelId = existing?.labelId; + _currency = existing?.currency ?? 'EUR'; + + if (existing?.sourceTxId != null) { + final txs = context.read().transactions; + for (final tx in txs) { + if (tx.id == existing!.sourceTxId) { + _pickedTx = tx; + break; + } + } + } + } + + @override + void dispose() { + _nameController.dispose(); + _dayController.dispose(); + _avgController.dispose(); + super.dispose(); + } + + int get _day { + final v = int.tryParse(_dayController.text.trim()) ?? 1; + return v.clamp(1, 28); + } + + int get _avg { + final v = int.tryParse(_avgController.text.trim()) ?? 5; + return v.clamp(1, 36); + } + + Future _pickTransaction() async { + final bank = context.read(); + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _TransactionPickerSheet( + transactions: bank.transactions, + ), + ); + if (picked == null || !mounted) return; + setState(() { + _pickedTx = picked; + _currency = picked.currency; + _source = RecurringTemplateSource.transaction; + if (_nameController.text.trim().isEmpty || widget.existing == null) { + _nameController.text = picked.description; + } + _dayController.text = + '${(DateTime.tryParse(picked.date)?.day ?? 1).clamp(1, 28)}'; + if (_rule == RecurringAmountRule.labelAvgMonths) { + _rule = RecurringAmountRule.lastValue; + } + }); + } + + Future _pickLabel() async { + final meta = context.read(); + final labels = [...meta.allLabels] + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _LabelPickerSheet(labels: labels), + ); + if (picked == null || !mounted) return; + setState(() { + _labelId = picked.id; + _source = RecurringTemplateSource.label; + _rule = RecurringAmountRule.labelAvgMonths; + if (_nameController.text.trim().isEmpty || widget.existing == null) { + _nameController.text = picked.name; + } + if (_dayController.text.trim().isEmpty) { + _dayController.text = '1'; + } + }); + } + + void _save() { + final name = _nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter a name')), + ); + return; + } + + if (_source == RecurringTemplateSource.transaction && _pickedTx == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Pick a transaction')), + ); + return; + } + if ((_source == RecurringTemplateSource.label || + _rule == RecurringAmountRule.labelAvgMonths) && + (_labelId == null || _labelId!.isEmpty)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Pick a label')), + ); + return; + } + + final existing = widget.existing; + final id = existing?.id.isNotEmpty == true + ? existing!.id + : 'rt_${DateTime.now().microsecondsSinceEpoch.toRadixString(16)}'; + + final tpl = RecurringTemplate( + id: id, + name: name, + dayOfMonth: _day, + currency: _currency, + source: _source, + amountRule: _source == RecurringTemplateSource.label + ? RecurringAmountRule.labelAvgMonths + : _rule, + merchantKey: _pickedTx?.merchantKey ?? existing?.merchantKey ?? '', + sourceTxId: _source == RecurringTemplateSource.transaction + ? _pickedTx?.id + : null, + labelId: (_source == RecurringTemplateSource.label || + _rule == RecurringAmountRule.labelAvgMonths) + ? _labelId + : null, + avgCount: _avg, + autoFromRecurring: existing?.autoFromRecurring ?? false, + ); + // Pop first so the sheet does not rebuild against the notify. + final meta = context.read(); + Navigator.pop(context); + meta.upsertRecurringTemplate(tpl); + } + + void _delete() { + final existing = widget.existing; + if (existing == null) return; + final meta = context.read(); + Navigator.pop(context); + meta.removeRecurringTemplate(existing.id); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final meta = context.read(); + final bank = context.read(); + final media = MediaQuery.of(context); + final label = meta.labelById(_labelId); + + final preview = resolveRecurringAmount( + template: RecurringTemplate( + id: 'preview', + name: _nameController.text, + dayOfMonth: _day, + currency: _currency, + source: _source, + amountRule: _source == RecurringTemplateSource.label + ? RecurringAmountRule.labelAvgMonths + : _rule, + merchantKey: _pickedTx?.merchantKey ?? '', + sourceTxId: _pickedTx?.id, + labelId: _labelId, + avgCount: _avg, + ), + transactions: bank.transactions, + isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, + labelIdsFor: meta.labelIdsFor, + ); + + return Padding( + padding: EdgeInsets.only(bottom: media.viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.existing == null ? 'New recurring template' : 'Edit template', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _dayController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Day of month (1–28)', + isDense: true, + ), + ), + const SizedBox(height: 16), + Text('Based on', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: RecurringTemplateSource.transaction, + label: Text('Transaction'), + icon: Icon(Icons.receipt_long, size: 18), + ), + ButtonSegment( + value: RecurringTemplateSource.label, + label: Text('Label'), + icon: Icon(Icons.label_outline, size: 18), + ), + ], + selected: {_source}, + onSelectionChanged: (next) { + setState(() { + _source = next.first; + if (_source == RecurringTemplateSource.label) { + _rule = RecurringAmountRule.labelAvgMonths; + } else if (_rule == RecurringAmountRule.labelAvgMonths) { + _rule = RecurringAmountRule.lastValue; + } + }); + }, + ), + const SizedBox(height: 12), + if (_source == RecurringTemplateSource.transaction) ...[ + OutlinedButton.icon( + onPressed: _pickTransaction, + icon: const Icon(Icons.search), + label: Text( + _pickedTx == null + ? 'Pick transaction' + : _pickedTx!.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 12), + Text('Amount', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ChoiceChip( + label: const Text('Last value'), + selected: _rule == RecurringAmountRule.lastValue, + onSelected: (_) => setState( + () => _rule = RecurringAmountRule.lastValue, + ), + ), + ChoiceChip( + label: const Text('Avg last X matching'), + selected: _rule == RecurringAmountRule.avgLastTxs, + onSelected: (_) => setState( + () => _rule = RecurringAmountRule.avgLastTxs, + ), + ), + ChoiceChip( + label: const Text('Label avg'), + selected: _rule == RecurringAmountRule.labelAvgMonths, + onSelected: (_) => setState( + () => _rule = RecurringAmountRule.labelAvgMonths, + ), + ), + ], + ), + if (_rule == RecurringAmountRule.avgLastTxs) ...[ + const SizedBox(height: 12), + TextField( + controller: _avgController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Number of matching transactions', + helperText: + 'Matches use the same merchant pattern and income/expense as the picked tx', + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ], + if (_rule == RecurringAmountRule.labelAvgMonths) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _pickLabel, + icon: Icon( + label?.icon ?? Icons.label_outline, + color: label?.color, + ), + label: Text(label?.name ?? 'Pick label'), + ), + const SizedBox(height: 12), + TextField( + controller: _avgController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Previous months', + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ], + ] else ...[ + OutlinedButton.icon( + onPressed: _pickLabel, + icon: Icon( + label?.icon ?? Icons.label_outline, + color: label?.color, + ), + label: Text(label?.name ?? 'Pick label'), + ), + const SizedBox(height: 12), + TextField( + controller: _avgController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Previous months to average', + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ], + const SizedBox(height: 16), + Text( + 'Preview: ${formatMoney(preview, _currency)}', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _save, + child: Text(widget.existing == null ? 'Create' : 'Save'), + ), + if (widget.existing != null) + TextButton( + onPressed: _delete, + child: Text( + 'Delete', + style: TextStyle(color: colors.error), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } +} + +class _TransactionPickerSheet extends StatefulWidget { + const _TransactionPickerSheet({required this.transactions}); + + final List transactions; + + @override + State<_TransactionPickerSheet> createState() => + _TransactionPickerSheetState(); +} + +class _TransactionPickerSheetState extends State<_TransactionPickerSheet> { + final _search = TextEditingController(); + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final needle = _search.text.trim().toLowerCase(); + final txs = [ + for (final tx in widget.transactions) + if (needle.isEmpty || + tx.description.toLowerCase().contains(needle) || + tx.merchantKey.contains(needle)) + tx, + ].take(80).toList(growable: false); + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _search, + decoration: const InputDecoration( + hintText: 'Search transactions', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ), + Expanded( + child: ListView.separated( + itemCount: txs.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final tx = txs[index]; + return ListTile( + title: Text( + tx.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${formatIsoDate(tx.date)} · ${formatMoney(tx.amount, tx.currency)}', + ), + onTap: () => Navigator.pop(context, tx), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _LabelPickerSheet extends StatelessWidget { + const _LabelPickerSheet({required this.labels}); + + final List labels; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: ListView.separated( + padding: const EdgeInsets.only(bottom: 16), + itemCount: labels.length, + separatorBuilder: (_, _) => const Divider(height: 1, indent: 56), + itemBuilder: (context, index) { + final label = labels[index]; + return ListTile( + leading: Icon(label.icon, color: label.color), + title: Text(label.name), + onTap: () => Navigator.pop(context, label), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/shell_tab_scope.dart b/lib/widgets/shell_tab_scope.dart new file mode 100644 index 0000000..b5ba71c --- /dev/null +++ b/lib/widgets/shell_tab_scope.dart @@ -0,0 +1,27 @@ +/// Provides the active bottom-nav tab index for [MainShell]. +library; + +import 'package:flutter/material.dart'; + +class ShellTabScope extends InheritedWidget { + const ShellTabScope({ + super.key, + required this.index, + required super.child, + }); + + final int index; + + static int of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'ShellTabScope not found'); + return scope!.index; + } + + static int? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.index; + } + + @override + bool updateShouldNotify(ShellTabScope oldWidget) => index != oldWidget.index; +} diff --git a/pocketbase/pb_migrations/1740000002_ingtrak_recurring_templates.js b/pocketbase/pb_migrations/1740000002_ingtrak_recurring_templates.js new file mode 100644 index 0000000..53626f3 --- /dev/null +++ b/pocketbase/pb_migrations/1740000002_ingtrak_recurring_templates.js @@ -0,0 +1,117 @@ +/// PocketBase migration: recurring look-ahead templates. +/// +/// Apply on the server (from /opt/pocketbase or wherever the binary lives): +/// +/// sudo cp this_file /opt/pocketbase/pb_migrations/ +/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js +/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up +/// sudo systemctl restart pocketbase +/// +/// Depends on usersapp (same owner rules as labels / tx_meta). +migrate((app) => { + const users = app.findCollectionByNameOrId("usersapp"); + + const ownerRules = { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "@request.auth.id != '' && user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }; + + const recurringTemplates = new Collection({ + type: "base", + name: "recurring_templates", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + // App-side RecurringTemplate.id + type: "text", + name: "client_id", + required: true, + min: 1, + max: 120, + }, + { + type: "text", + name: "name", + required: true, + min: 1, + max: 400, + }, + { + type: "number", + name: "day_of_month", + required: true, + }, + { + type: "text", + name: "currency", + required: true, + min: 1, + max: 8, + }, + { + // "transaction" | "label" + type: "text", + name: "source", + required: true, + min: 1, + max: 32, + }, + { + // "lastValue" | "avgLastTxs" | "labelAvgMonths" + type: "text", + name: "amount_rule", + required: true, + min: 1, + max: 32, + }, + { + type: "text", + name: "merchant_key", + required: false, + max: 500, + }, + { + type: "text", + name: "source_tx_id", + required: false, + max: 2000, + }, + { + type: "text", + name: "label_id", + required: false, + max: 80, + }, + { + type: "number", + name: "avg_count", + required: false, + }, + { + type: "bool", + name: "auto_from_recurring", + required: false, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_recurring_templates_user_client ON recurring_templates (user, client_id)", + ], + }); + app.save(recurringTemplates); +}, (app) => { + try { + const col = app.findCollectionByNameOrId("recurring_templates"); + app.delete(col); + } catch (_) {} +});