diff --git a/lib/providers/preferences_provider.dart b/lib/providers/preferences_provider.dart index afd03d7..2974c76 100644 --- a/lib/providers/preferences_provider.dart +++ b/lib/providers/preferences_provider.dart @@ -3,6 +3,7 @@ library; import 'package:flutter/foundation.dart'; +import '../models/app_cloud_prefs.dart'; import '../models/home_built_in_sections.dart'; import '../models/home_filter_widget.dart'; import '../models/overview_favourite.dart'; @@ -28,9 +29,18 @@ class PreferencesController extends ChangeNotifier { /// Fired after Home widgets are persisted (for cloud push debounce). VoidCallback? onHomeWidgetsPersisted; + /// Fired after Home sections / Look ahead are persisted. + VoidCallback? onAppPrefsPersisted; + /// Years ahead Look ahead can navigate (1–30, default 5). int get lookAheadMaxYears => _lookAheadMaxYears; + /// Snapshot used for PocketBase `app_prefs` sync. + AppCloudPrefs get cloudPrefs => AppCloudPrefs( + homeBuiltInSections: _homeBuiltInSections, + lookAheadMaxYears: _lookAheadMaxYears, + ); + List get overviewFavourites => List.unmodifiable(_overviewFavourites); @@ -55,7 +65,10 @@ class PreferencesController extends ChangeNotifier { HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections; - Future setHomeBuiltInSections(HomeBuiltInSections sections) async { + Future setHomeBuiltInSections( + HomeBuiltInSections sections, { + bool schedulePush = true, + }) async { if (sections.showTransactions == _homeBuiltInSections.showTransactions && sections.showCategorized == _homeBuiltInSections.showCategorized && sections.showRecent == _homeBuiltInSections.showRecent) { @@ -64,6 +77,7 @@ class PreferencesController extends ChangeNotifier { _homeBuiltInSections = sections; notifyListeners(); await _storage.saveHomeBuiltInSections(sections); + if (schedulePush) onAppPrefsPersisted?.call(); } Future setHomeShowTransactions(bool value) => setHomeBuiltInSections( @@ -78,12 +92,29 @@ class PreferencesController extends ChangeNotifier { _homeBuiltInSections.copyWith(showRecent: value), ); - Future setLookAheadMaxYears(int years) async { + Future setLookAheadMaxYears( + int years, { + bool schedulePush = true, + }) async { final next = years.clamp(1, 30); if (next == _lookAheadMaxYears) return; _lookAheadMaxYears = next; notifyListeners(); await _storage.setLookAheadMaxYears(next); + if (schedulePush) onAppPrefsPersisted?.call(); + } + + Future replaceCloudPrefs( + AppCloudPrefs prefs, { + bool notify = true, + bool schedulePush = false, + }) async { + _homeBuiltInSections = prefs.homeBuiltInSections; + _lookAheadMaxYears = prefs.lookAheadMaxYears.clamp(1, 30); + if (notify) notifyListeners(); + await _storage.saveHomeBuiltInSections(_homeBuiltInSections); + await _storage.setLookAheadMaxYears(_lookAheadMaxYears); + if (schedulePush) onAppPrefsPersisted?.call(); } Future addOverviewFavourite(OverviewFavourite favourite) async { diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index dbcb51c..93b2fb2 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/app_cloud_prefs.dart'; import '../models/home_filter_widget.dart'; import '../models/overview_favourite.dart'; import '../services/pb_sync_service.dart'; @@ -42,6 +43,7 @@ class SyncController extends ChangeNotifier { _meta.onPersisted = _scheduleMetaPush; _prefs.onFavouritesPersisted = _scheduleFavouritesPush; _prefs.onHomeWidgetsPersisted = _scheduleHomeWidgetsPush; + _prefs.onAppPrefsPersisted = _scheduleAppPrefsPush; _auth.addListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = _onBankDataLoaded; if (_auth.isLoggedIn) { @@ -61,6 +63,7 @@ class SyncController extends ChangeNotifier { bool _pushMetaPending = false; bool _pushFavouritesPending = false; bool _pushHomeWidgetsPending = false; + bool _pushAppPrefsPending = false; Timer? _pushDebounce; Timer? _progressHold; Timer? _uiNotifyThrottle; @@ -194,6 +197,7 @@ class SyncController extends ChangeNotifier { _pushMetaPending = false; _pushFavouritesPending = false; _pushHomeWidgetsPending = false; + _pushAppPrefsPending = false; _lastError = null; _statusDetail = ''; _showProgressRing = false; @@ -256,6 +260,11 @@ class SyncController extends ChangeNotifier { _armPushDebounce(); } + void _scheduleAppPrefsPush() { + _pushAppPrefsPending = true; + _armPushDebounce(); + } + void _armPushDebounce() { if (!_auth.isLoggedIn) return; if (_syncing) { @@ -332,6 +341,20 @@ class SyncController extends ChangeNotifier { _prefs.homeFilterWidgets, remoteHomeWidgets, ); + AppCloudPrefs? remoteAppPrefs; + try { + remoteAppPrefs = await service.pullAppPrefs(onStatus: _setStatus); + } catch (error) { + debugPrint('SyncController: app_prefs pull skipped ($error)'); + } + // Prefer local when this device has pending prefs edits; otherwise + // take remote so a new install picks up cloud Home section toggles. + final preferLocalAppPrefs = _pushAppPrefsPending; + final mergedAppPrefs = mergeAppCloudPrefs( + local: _prefs.cloudPrefs, + remote: remoteAppPrefs, + preferLocal: preferLocalAppPrefs, + ); // Apply without notifying — one rebuild after both stores are ready. await _meta.replaceStore(mergedMeta, notify: false); await _bank.applySyncedTransactions(remoteTxs, notify: false); @@ -343,6 +366,7 @@ class SyncController extends ChangeNotifier { mergedHomeWidgets, notify: false, ); + await _prefs.replaceCloudPrefs(mergedAppPrefs, notify: false); _meta.notifyListeners(); _bank.notifyListeners(); _prefs.notifyListeners(); @@ -375,6 +399,15 @@ class SyncController extends ChangeNotifier { 'SyncController: home_filter_widgets push skipped ($error)', ); } + try { + await service.pushAppPrefs( + mergedAppPrefs, + onStatus: _setStatus, + ); + _pushAppPrefsPending = false; + } catch (error) { + debugPrint('SyncController: app_prefs push skipped ($error)'); + } final bankSummary = await service.pushTransactions( _bank.transactions, progressStart: 0.82, @@ -420,20 +453,31 @@ class SyncController extends ChangeNotifier { final pushMeta = _pushMetaPending; final pushFavourites = _pushFavouritesPending; final pushHomeWidgets = _pushHomeWidgetsPending; + final pushAppPrefs = _pushAppPrefsPending; _pushMetaPending = false; _pushFavouritesPending = false; _pushHomeWidgetsPending = false; - if (!pushMeta && !pushFavourites && !pushHomeWidgets) return; + _pushAppPrefsPending = false; + if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs) { + return; + } _progressHold?.cancel(); _syncing = true; _progressSuccess = false; - final label = switch ((pushMeta, pushFavourites, pushHomeWidgets)) { - (false, false, true) => 'Home widgets', - (false, true, false) => 'Favourites', - (false, true, true) => 'Home layout', - (true, false, false) => 'Annotations', + final label = switch (( + pushMeta, + pushFavourites, + pushHomeWidgets, + pushAppPrefs, + )) { + (false, false, false, true) => 'App prefs', + (false, false, true, false) => 'Home widgets', + (false, false, true, true) => 'Home layout', + (false, true, false, false) => 'Favourites', + (false, true, true, _) => 'Home layout', + (true, false, false, false) => 'Annotations', _ => 'Changes', }; _statusDetail = 'Pushing $label…'; @@ -446,7 +490,8 @@ class SyncController extends ChangeNotifier { var done = 0; final total = (pushMeta ? 1 : 0) + (pushFavourites ? 1 : 0) + - (pushHomeWidgets ? 1 : 0); + (pushHomeWidgets ? 1 : 0) + + (pushAppPrefs ? 1 : 0); void bump() { done++; _setProgress(done / total); @@ -494,6 +539,22 @@ class SyncController extends ChangeNotifier { } bump(); } + if (pushAppPrefs) { + _setStatus('Pushing app prefs…'); + try { + await service.pushAppPrefs( + _prefs.cloudPrefs, + onStatus: _setStatus, + ); + parts.add('App prefs updated'); + } catch (error) { + debugPrint('SyncController: app_prefs push skipped ($error)'); + // Re-queue so a missing collection doesn't drop the edit forever + // until the next manual full sync after migrate. + _pushAppPrefsPending = true; + } + bump(); + } _setProgress(1.0); _lastSyncedAt = DateTime.now(); @@ -530,6 +591,7 @@ class SyncController extends ChangeNotifier { _meta.onPersisted = null; _prefs.onFavouritesPersisted = null; _prefs.onHomeWidgetsPersisted = null; + _prefs.onAppPrefsPersisted = null; super.dispose(); } } diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index 5c84edc..48f9bad 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -7,7 +7,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:pocketbase/pocketbase.dart'; +import '../models/app_cloud_prefs.dart'; import '../models/bank.dart'; +import '../models/home_built_in_sections.dart'; import '../models/home_filter_widget.dart'; import '../models/overview_favourite.dart'; import '../models/transaction_meta.dart'; @@ -558,6 +560,43 @@ class PbSyncService { await _syncHomeFilterWidgets(userId, filter, widgets); } + /// Pull per-user app prefs (Home sections + Look ahead). Soft-fail if missing. + Future pullAppPrefs({ + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Pulling app prefs…'); + final records = await _fullList('app_prefs', filter); + if (records.isEmpty) { + debugPrint('PbSyncService pull: app_prefs=0'); + return null; + } + final r = records.first; + final prefs = AppCloudPrefs( + homeBuiltInSections: HomeBuiltInSections( + showTransactions: r.getBoolValue('home_show_transactions', true), + showCategorized: r.getBoolValue('home_show_categorized', true), + showRecent: r.getBoolValue('home_show_recent', true), + ), + lookAheadMaxYears: + r.getIntValue('look_ahead_max_years', 5).clamp(1, 30), + ); + debugPrint('PbSyncService pull: app_prefs=1'); + return prefs; + } + + /// Upsert the single app_prefs row for this user. + Future pushAppPrefs( + AppCloudPrefs prefs, { + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Syncing app prefs…'); + await _syncAppPrefs(userId, filter, prefs); + } + /// Upserts local bank txs to PocketBase. Does **not** delete remote rows /// (partial local caches must not wipe another device). Future pushTransactions( @@ -832,6 +871,10 @@ class PbSyncService { filter = 'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"'; } + case 'app_prefs': + if (userId != null) { + filter = 'user="$userId"'; + } case 'transactions': final key = body['tx_id']; if (userId != null && key is String) { @@ -1249,6 +1292,36 @@ class PbSyncService { await _runWriteOps('home_filter_widgets', ops); } + Future _syncAppPrefs( + String userId, + String filter, + AppCloudPrefs prefs, + ) async { + final existing = await _fullList('app_prefs', filter); + final body = { + 'user': userId, + 'home_show_transactions': prefs.homeBuiltInSections.showTransactions, + 'home_show_categorized': prefs.homeBuiltInSections.showCategorized, + 'home_show_recent': prefs.homeBuiltInSections.showRecent, + 'look_ahead_max_years': prefs.lookAheadMaxYears.clamp(1, 30), + }; + final ops = <_BatchOp>[]; + if (existing.isEmpty) { + ops.add(_BatchOp.create(body)); + } else { + final remote = existing.first; + if (!_appPrefsUnchanged(remote, prefs)) { + ops.add(_BatchOp.update(remote.id, body)); + } + // Enforce singleton: drop extras if any. + for (var i = 1; i < existing.length; i++) { + ops.add(_BatchOp.delete(existing[i].id)); + } + } + debugPrint('PbSyncService app_prefs: remote=${existing.length} ops=${ops.length}'); + await _runWriteOps('app_prefs', ops); + } + bool _labelUnchanged(RecordModel remote, Map body) { return remote.getStringValue('name') == body['name'] && remote.getIntValue('color') == body['color'] && @@ -1339,6 +1412,17 @@ class PbSyncService { remote.getBoolValue('show_amount_out', true) == w.showAmountOut && remote.getBoolValue('show_count', true) == w.showCount; } + + bool _appPrefsUnchanged(RecordModel remote, AppCloudPrefs prefs) { + final s = prefs.homeBuiltInSections; + return remote.getBoolValue('home_show_transactions', true) == + s.showTransactions && + remote.getBoolValue('home_show_categorized', true) == + s.showCategorized && + remote.getBoolValue('home_show_recent', true) == s.showRecent && + remote.getIntValue('look_ahead_max_years', 5) == + prefs.lookAheadMaxYears.clamp(1, 30); + } } RecurringTemplateSource _parseTemplateSource(String raw) {