diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index 08e0c81..87067fa 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -36,6 +36,12 @@ class BankController extends ChangeNotifier { final IngtrakApi _api; final StorageService? _storage; + /// Fired after a successful live balance + transactions fetch. + /// + /// Used by [SyncController] to reconcile with PocketBase so a bank refresh + /// cannot leave the device behind the cloud (or wipe cloud-only history). + VoidCallback? onAfterSuccessfulLoad; + ConnectStatus _status = ConnectStatus.disconnected; BankBalance? _balance; List _remoteTransactions = const []; @@ -193,6 +199,7 @@ class BankController extends ChangeNotifier { _error = null; notifyListeners(); + var loadedLive = false; try { try { _status = await _api.fetchStatus(); @@ -209,7 +216,7 @@ class BankController extends ChangeNotifier { // have a local snapshot. Never wipe local txs because status said // "unlinked" — only a 409 from balance/transactions clears them. if (_status.linked || hasData) { - await _loadLinkedData(); + loadedLive = await _loadLinkedData(); await _persistCache(); } } on IngtrakApiException catch (error, stackTrace) { @@ -230,16 +237,24 @@ class BankController extends ChangeNotifier { _isLoading = false; notifyListeners(); } + + if (loadedLive) { + onAfterSuccessfulLoad?.call(); + } } - Future _loadLinkedData() async { + /// Returns true when balance + transactions were fetched successfully. + Future _loadLinkedData() async { try { final results = await Future.wait([ _api.fetchBalance(), _api.fetchTransactions(), ]); _balance = results[0] as BankBalance; - _remoteTransactions = results[1] as List; + final apiTxs = results[1] as List; + // Bank API is ~89 days. Prefer API rows for overlaps, but keep any + // cloud/CSV history that the API window does not include. + _remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions); if (!_status.linked) { _status = ConnectStatus( status: 'linked', @@ -257,6 +272,7 @@ class BankController extends ChangeNotifier { _invalidateMerged(); await _persistImported(); _error = null; + return true; } on IngtrakApiException catch (error) { if (error.isNotLinked) { // Confirmed unlinked by data endpoints — clear remote only. @@ -272,6 +288,7 @@ class BankController extends ChangeNotifier { // Keep previous balance/txs if refresh of data fails. _error = 'Using saved data · ${error.message}'; } + return false; } } @@ -349,11 +366,19 @@ class BankController extends ChangeNotifier { state: state ?? _pendingState, ); _pendingState = null; + var loadedLive = false; if (_status.linked) { - await _loadLinkedData(); + loadedLive = await _loadLinkedData(); await _persistCache(); } - return _status.linked; + final linked = _status.linked; + // Notify before cloud reconcile so UI can leave the connecting state. + _isLoading = false; + notifyListeners(); + if (loadedLive) { + onAfterSuccessfulLoad?.call(); + } + return linked; } on IngtrakApiException catch (error, stackTrace) { _error = error.message; debugPrint('BankController.completeWithCode failed: $error\n$stackTrace'); @@ -363,13 +388,16 @@ class BankController extends ChangeNotifier { debugPrint('BankController.completeWithCode failed: $error\n$stackTrace'); return false; } finally { - _isLoading = false; - notifyListeners(); + if (_isLoading) { + _isLoading = false; + notifyListeners(); + } } } @override void dispose() { + onAfterSuccessfulLoad = null; _api.close(); super.dispose(); } diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 6ebcba6..72e1b65 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -36,6 +36,7 @@ class SyncController extends ChangeNotifier { _wasLoggedIn = auth.isLoggedIn; _meta.onPersisted = _schedulePush; _auth.addListener(_onAuthChanged); + _bank.onAfterSuccessfulLoad = _onBankDataLoaded; if (_auth.isLoggedIn) { unawaited(syncNow()); } @@ -188,6 +189,33 @@ class SyncController extends ChangeNotifier { } } + /// Bank pull-to-refresh / reconnect finished — reconcile with PocketBase. + /// + /// Always runs a full sync when logged in so (1) new API txs are pushed and + /// (2) any cloud-only history the bank window lacks is pulled back. + void _onBankDataLoaded() { + if (!_auth.isLoggedIn) return; + unawaited(syncNow()); + } + + /// If the server has more bank txs than this device, sync now. + Future syncIfBehindServer() async { + if (!_auth.isLoggedIn || _syncing) return; + try { + final remoteCount = + await PbSyncService(_auth.client).countTransactions(); + if (remoteCount > _bank.transactions.length) { + debugPrint( + 'SyncController: behind server ' + '(local=${_bank.transactions.length} remote=$remoteCount) — syncing', + ); + await syncNow(); + } + } catch (error, stackTrace) { + debugPrint('SyncController.syncIfBehindServer failed: $error\n$stackTrace'); + } + } + void _schedulePush() { if (!_auth.isLoggedIn) return; _pushDebounce?.cancel(); @@ -329,6 +357,7 @@ class SyncController extends ChangeNotifier { _progressHold?.cancel(); _uiNotifyThrottle?.cancel(); _auth.removeListener(_onAuthChanged); + _bank.onAfterSuccessfulLoad = null; _meta.onPersisted = null; super.dispose(); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 42ab80d..69cce8c 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -2,7 +2,9 @@ library; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/sync_provider.dart'; import '../widgets/shell_tab_scope.dart'; import 'home_screen.dart'; import 'look_ahead_screen.dart'; @@ -24,6 +26,15 @@ class _MainShellState extends State { LookAheadScreen(), ]; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().syncIfBehindServer(); + }); + } + @override Widget build(BuildContext context) { return ShellTabScope( diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index a9076e9..ebd92f9 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -335,6 +335,22 @@ class PbSyncService { ); } + /// Cheap remote count for stale detection (1 row, with totals). + Future countTransactions() async { + final userId = _userId; + final filter = 'user="$userId"'; + final result = await _withTimeout( + _pb.collection('transactions').getList( + page: 1, + perPage: 1, + filter: filter, + skipTotal: false, + ), + label: 'transactions count', + ); + return result.totalItems; + } + /// Pulls the full bank transaction list for the signed-in user. Future> pullTransactions({ void Function(double progress)? onProgress, diff --git a/test/pb_sync_merge_test.dart b/test/pb_sync_merge_test.dart index 1e37974..86cd29d 100644 --- a/test/pb_sync_merge_test.dart +++ b/test/pb_sync_merge_test.dart @@ -97,4 +97,40 @@ void main() { ])); expect(merged.first.date, '2024-01-03'); }); + + test('bank API merge keeps cloud-only txs outside the API window', () { + const cloudOnly = BankTransaction( + date: '2020-06-01', + description: 'Old cloud tx', + amount: -20, + currency: 'EUR', + pending: false, + ); + const recentShared = BankTransaction( + date: '2026-09-01', + description: 'Recent Shop', + amount: -5, + currency: 'EUR', + pending: false, + ); + const apiOnly = BankTransaction( + date: '2026-09-02', + description: 'New API tx', + amount: -3, + currency: 'EUR', + pending: false, + ); + + // Same order as BankController._loadLinkedData: API first, then existing. + final afterRefresh = mergeBankTransactionLists( + [recentShared, apiOnly], + [cloudOnly, recentShared], + ); + + expect(afterRefresh.length, 3); + expect( + afterRefresh.map((t) => t.description), + containsAll(['Old cloud tx', 'Recent Shop', 'New API tx']), + ); + }); }