Files
ingtrakapp/lib/providers/sync_provider.dart
T
2026-09-10 23:59:00 +02:00

664 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Coordinates PocketBase ↔ local annotation + bank-transaction sync.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/app_cloud_prefs.dart';
import '../models/bank.dart';
import '../models/home_filter_widget.dart';
import '../models/overview_favourite.dart';
import '../services/pb_sync_service.dart';
import 'auth_provider.dart';
import 'bank_provider.dart';
import 'preferences_provider.dart';
import 'tx_meta_provider.dart';
/// User-visible sync toast state (watched by [SyncFeedbackListener]).
class SyncFeedback {
const SyncFeedback({
required this.epoch,
required this.message,
this.isProgress = false,
this.isError = false,
});
final int epoch;
final String message;
final bool isProgress;
final bool isError;
}
class SyncController extends ChangeNotifier {
SyncController({
required AuthController auth,
required TxMetaController meta,
required BankController bank,
required PreferencesController prefs,
}) : _auth = auth,
_meta = meta,
_bank = bank,
_prefs = prefs {
_wasLoggedIn = auth.isLoggedIn;
_meta.onPersisted = _scheduleMetaPush;
_prefs.onFavouritesPersisted = _scheduleFavouritesPush;
_prefs.onHomeWidgetsPersisted = _scheduleHomeWidgetsPush;
_prefs.onAppPrefsPersisted = _scheduleAppPrefsPush;
_auth.addListener(_onAuthChanged);
_bank.onAfterSuccessfulLoad = _onBankDataLoaded;
_bank.onPendingRemoteDeletesChanged = _scheduleBankTxPush;
if (_auth.isLoggedIn) {
unawaited(syncNow());
}
}
final AuthController _auth;
final TxMetaController _meta;
final BankController _bank;
final PreferencesController _prefs;
bool _wasLoggedIn = false;
bool _syncing = false;
bool _pushQueued = false;
bool _queuedFullSync = false;
bool _pushMetaPending = false;
bool _pushFavouritesPending = false;
bool _pushHomeWidgetsPending = false;
bool _pushAppPrefsPending = false;
bool _pushBankPending = false;
Timer? _pushDebounce;
Timer? _progressHold;
Timer? _uiNotifyThrottle;
var _uiNotifyQueued = false;
String? _lastError;
DateTime? _lastSyncedAt;
String _lastSyncSummary = '';
int _feedbackEpoch = 0;
SyncFeedback? _feedback;
/// Live detail for the avatar menu / tooltip (does not re-fire SnackBars).
String _statusDetail = '';
/// 0–1 while the avatar ring is visible.
double _syncProgress = 0;
bool _showProgressRing = false;
bool _progressSuccess = false;
int _lastNotifiedProgressPct = -1;
bool get isSyncing => _syncing;
String? get lastError => _lastError;
DateTime? get lastSyncedAt => _lastSyncedAt;
bool get canSync => _auth.isLoggedIn;
SyncFeedback? get feedback => _feedback;
/// After a successful sync, e.g. annotation + bank tx counts.
String get lastSyncSummary => _lastSyncSummary;
/// Short live status, e.g. `Writing tx_meta (40/200)…`.
String get statusDetail => _statusDetail;
/// Determinate progress for the avatar ring (0–1).
double get syncProgress => _syncProgress;
/// Whether to paint the progress ring (includes brief success flash).
bool get showProgressRing => _showProgressRing;
/// 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(
epoch: _feedbackEpoch,
message: message,
isProgress: isProgress,
isError: isError,
);
_notifyUiNow();
}
void _setProgress(double value, {bool success = false}) {
final next = value.clamp(0.0, 1.0);
final pct = (next * 100).round();
_syncProgress = next;
_showProgressRing = true;
_progressSuccess = success;
// 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;
_notifyUiThrottled();
}
void _finishProgress({required bool success}) {
_progressHold?.cancel();
_uiNotifyThrottle?.cancel();
_uiNotifyQueued = false;
_statusDetail = '';
if (success) {
_setProgress(1, success: true);
_progressHold = Timer(const Duration(milliseconds: 900), () {
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
_lastNotifiedProgressPct = -1;
_notifyUiNow();
});
} else {
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
_lastNotifiedProgressPct = -1;
_notifyUiNow();
}
}
void _onAuthChanged() {
final loggedIn = _auth.isLoggedIn;
if (loggedIn && !_wasLoggedIn) {
unawaited(syncNow());
}
_wasLoggedIn = loggedIn;
if (!loggedIn) {
_pushDebounce?.cancel();
_progressHold?.cancel();
_uiNotifyThrottle?.cancel();
_uiNotifyQueued = false;
_pushQueued = false;
_pushMetaPending = false;
_pushFavouritesPending = false;
_pushHomeWidgetsPending = false;
_pushAppPrefsPending = false;
_pushBankPending = false;
_lastError = null;
_statusDetail = '';
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
_lastNotifiedProgressPct = -1;
_notifyUiNow();
}
}
/// Bank pull-to-refresh / reconnect finished — reconcile with PocketBase.
///
/// Runs a full sync when idle so (1) new API txs are pushed and
/// (2) any cloud-only history the bank window lacks is pulled back.
///
/// If a sync is already in flight (common at cold start: constructor sync
/// overlaps bank refresh), do **not** queue another full sync — that was
/// causing a back-to-back double pull of 4k+ txs.
void _onBankDataLoaded() {
if (!_auth.isLoggedIn) return;
if (_syncing) {
debugPrint(
'SyncController: bank load finished during sync — skip queued full sync',
);
return;
}
unawaited(syncNow());
}
/// If the server has more bank txs than this device, sync now.
Future<void> 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 _scheduleMetaPush() {
_pushMetaPending = true;
_armPushDebounce();
}
void _scheduleFavouritesPush() {
_pushFavouritesPending = true;
_armPushDebounce();
}
void _scheduleHomeWidgetsPush() {
_pushHomeWidgetsPending = true;
_armPushDebounce();
}
void _scheduleAppPrefsPush() {
_pushAppPrefsPending = true;
_armPushDebounce();
}
void _scheduleBankTxPush() {
_pushBankPending = true;
_armPushDebounce();
}
/// Removes tombstoned bank txs from PocketBase before pull/push can resurrect them.
Future<void> _flushPendingTxDeletes(PbSyncService service) async {
final pending = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
if (pending.isEmpty) return;
_setStatus('Removing deleted bank txs…');
try {
final matched = await service.deleteTransactionsMatching(pending);
if (matched.isNotEmpty) {
_bank.clearRemoteDeletesMatching(matched);
}
// Do NOT clear unmatched tombstones — they still block pull resurrection
// until pushTransactions GC removes the remote rows.
} catch (error) {
debugPrint('SyncController: tx deletes failed ($error)');
}
}
void _armPushDebounce() {
if (!_auth.isLoggedIn) return;
if (_syncing) {
_pushQueued = true;
return;
}
_pushDebounce?.cancel();
_pushDebounce = Timer(const Duration(seconds: 2), () {
unawaited(_pushOnly());
});
}
/// Pull → merge with local (no data loss) → save → push.
///
/// Includes annotations and the full bank transaction list.
Future<void> syncNow() async {
if (!_auth.isLoggedIn) return;
if (_syncing) {
_queuedFullSync = true;
return;
}
_progressHold?.cancel();
_syncing = true;
_lastError = null;
_progressSuccess = false;
_statusDetail = 'Starting…';
_setProgress(0.02);
_announce('Syncing…', isProgress: true);
try {
final service = PbSyncService(_auth.client);
// Delete first — otherwise pull re-merges the row and push recreates it.
await _flushPendingTxDeletes(service);
final remoteMeta = await service.pull(
onProgress: (p) {
_setProgress(p * 0.35);
},
onStatus: _setStatus,
);
_setProgress(0.36);
final remoteTxs = await service.pullTransactions(
onProgress: (p) {
_setProgress(0.36 + p * 0.12);
},
onStatus: _setStatus,
);
_setProgress(0.5);
_setStatus('Merging…');
final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta);
List<OverviewFavourite> remoteFavourites = const [];
try {
remoteFavourites = await service.pullOverviewFavourites(
onStatus: _setStatus,
);
} catch (error) {
debugPrint(
'SyncController: overview_favourites pull skipped ($error)',
);
}
final mergedFavourites = mergeOverviewFavourites(
_prefs.overviewFavourites,
remoteFavourites,
);
List<HomeFilterWidget> remoteHomeWidgets = const [];
try {
remoteHomeWidgets = await service.pullHomeFilterWidgets(
onStatus: _setStatus,
);
} catch (error) {
debugPrint(
'SyncController: home_filter_widgets pull skipped ($error)',
);
}
final mergedHomeWidgets = mergeHomeFilterWidgets(
_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);
await _prefs.replaceOverviewFavourites(
mergedFavourites,
notify: false,
);
await _prefs.replaceHomeFilterWidgets(
mergedHomeWidgets,
notify: false,
);
await _prefs.replaceCloudPrefs(mergedAppPrefs, notify: false);
_meta.notifyListeners();
_bank.notifyListeners();
_prefs.notifyListeners();
_setProgress(0.55);
_setStatus('Pushing…');
final metaSummary = await service.push(
mergedMeta,
progressStart: 0.55,
progressEnd: 0.75,
onProgress: _setProgress,
onStatus: _setStatus,
);
try {
await service.pushOverviewFavourites(
mergedFavourites,
onStatus: _setStatus,
);
} catch (error) {
debugPrint(
'SyncController: overview_favourites push skipped ($error)',
);
}
try {
await service.pushHomeFilterWidgets(
mergedHomeWidgets,
onStatus: _setStatus,
);
} catch (error) {
debugPrint(
'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)');
}
// Retry deletes in case any were queued during the pull/merge window.
await _flushPendingTxDeletes(service);
_pushBankPending = false;
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
final bankSummary = await service.pushTransactions(
_bank.transactions,
deleteTombs: tombs,
progressStart: 0.82,
progressEnd: 1.0,
onProgress: _setProgress,
onStatus: _setStatus,
);
// Push GC removes orphan admin test rows; tombstones are done.
_bank.clearAllPendingRemoteDeletes();
_lastSyncedAt = DateTime.now();
_lastError = null;
_lastSyncSummary = '$metaSummary\n$bankSummary';
_announce('Sync complete');
_syncing = false;
_finishProgress(success: true);
} catch (error, stackTrace) {
debugPrint('SyncController.syncNow failed: $error\n$stackTrace');
_lastError = error.toString().replaceFirst(RegExp(r'^Bad state:\s*'), '');
_announce('Sync failed', isError: true);
_syncing = false;
_finishProgress(success: false);
} finally {
if (_queuedFullSync) {
_queuedFullSync = false;
_pushQueued = false;
unawaited(syncNow());
} else if (_pushQueued) {
_pushQueued = false;
unawaited(_pushOnly());
}
}
}
/// Pushes only the stores that changed locally (debounced).
///
/// Annotation edits push tx_meta; favourite / Home-widget edits do not
/// re-upload the full annotation store or bank transactions.
Future<void> _pushOnly() async {
if (!_auth.isLoggedIn) return;
if (_syncing) {
_pushQueued = true;
return;
}
final pushMeta = _pushMetaPending;
final pushFavourites = _pushFavouritesPending;
final pushHomeWidgets = _pushHomeWidgetsPending;
final pushAppPrefs = _pushAppPrefsPending;
final pushBank = _pushBankPending || _bank.pendingRemoteDeletes.isNotEmpty;
_pushMetaPending = false;
_pushFavouritesPending = false;
_pushHomeWidgetsPending = false;
_pushAppPrefsPending = false;
_pushBankPending = false;
if (!pushMeta &&
!pushFavourites &&
!pushHomeWidgets &&
!pushAppPrefs &&
!pushBank) {
return;
}
_progressHold?.cancel();
_syncing = true;
_progressSuccess = false;
final label = switch ((
pushMeta,
pushFavourites,
pushHomeWidgets,
pushAppPrefs,
pushBank,
)) {
(false, false, false, false, true) => 'Bank txs',
(false, false, false, true, false) => 'App prefs',
(false, false, true, false, false) => 'Home widgets',
(false, false, true, true, _) => 'Home layout',
(false, true, false, false, false) => 'Favourites',
(false, true, true, _, _) => 'Home layout',
(true, false, false, false, false) => 'Annotations',
_ => 'Changes',
};
_statusDetail = 'Pushing $label…';
_setProgress(0.05);
_announce('Saving $label…', isProgress: true);
try {
final service = PbSyncService(_auth.client);
final parts = <String>[];
var done = 0;
final total = (pushMeta ? 1 : 0) +
(pushFavourites ? 1 : 0) +
(pushHomeWidgets ? 1 : 0) +
(pushAppPrefs ? 1 : 0) +
(pushBank ? 1 : 0);
void bump() {
done++;
_setProgress(done / total);
}
if (pushMeta) {
_setStatus('Pushing annotations…');
final summary = await service.push(
_meta.exportStore(),
progressStart: done / total,
progressEnd: (done + 1) / total,
onProgress: _setProgress,
onStatus: _setStatus,
);
parts.add(summary);
bump();
}
if (pushFavourites) {
_setStatus('Pushing favourites…');
try {
await service.pushOverviewFavourites(
_prefs.overviewFavourites,
onStatus: _setStatus,
);
parts.add('Favourites updated');
} catch (error) {
debugPrint(
'SyncController: overview_favourites push skipped ($error)',
);
}
bump();
}
if (pushHomeWidgets) {
_setStatus('Pushing home widgets…');
try {
await service.pushHomeFilterWidgets(
_prefs.homeFilterWidgets,
onStatus: _setStatus,
);
parts.add('Home widgets updated');
} catch (error) {
debugPrint(
'SyncController: home_filter_widgets push skipped ($error)',
);
}
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();
}
if (pushBank) {
_setStatus('Updating bank txs…');
await _flushPendingTxDeletes(service);
try {
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
final summary = await service.pushTransactions(
_bank.transactions,
deleteTombs: tombs,
progressStart: done / total,
progressEnd: (done + 1) / total,
onProgress: _setProgress,
onStatus: _setStatus,
);
_bank.clearAllPendingRemoteDeletes();
parts.add(summary);
} catch (error) {
debugPrint('SyncController: bank tx push skipped ($error)');
_pushBankPending = true;
}
bump();
}
_setProgress(1.0);
_lastSyncedAt = DateTime.now();
_lastError = null;
_lastSyncSummary = parts.isEmpty ? label : parts.join('\n');
_announce('Saved $label');
_syncing = false;
_finishProgress(success: true);
} catch (error, stackTrace) {
debugPrint('SyncController.push failed: $error\n$stackTrace');
_lastError = error.toString().replaceFirst(RegExp(r'^Bad state:\s*'), '');
_announce('Sync failed', isError: true);
_syncing = false;
_finishProgress(success: false);
} finally {
if (_queuedFullSync) {
_queuedFullSync = false;
_pushQueued = false;
unawaited(syncNow());
} else if (_pushQueued) {
_pushQueued = false;
unawaited(_pushOnly());
}
}
}
@override
void dispose() {
_pushDebounce?.cancel();
_progressHold?.cancel();
_uiNotifyThrottle?.cancel();
_auth.removeListener(_onAuthChanged);
_bank.onAfterSuccessfulLoad = null;
_bank.onPendingRemoteDeletesChanged = null;
_meta.onPersisted = null;
_prefs.onFavouritesPersisted = null;
_prefs.onHomeWidgetsPersisted = null;
_prefs.onAppPrefsPersisted = null;
super.dispose();
}
}