607 lines
18 KiB
Dart
607 lines
18 KiB
Dart
/// Coordinates PocketBase ↔ local annotation + bank-transaction sync.
|
||
library;
|
||
|
||
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';
|
||
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;
|
||
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;
|
||
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;
|
||
_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 _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);
|
||
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)');
|
||
}
|
||
final pendingDeletes = _bank.takePendingRemoteDeletes();
|
||
if (pendingDeletes.isNotEmpty) {
|
||
try {
|
||
await service.deleteTransactionsByTxIds(pendingDeletes);
|
||
} catch (error) {
|
||
debugPrint('SyncController: tx deletes skipped ($error)');
|
||
_bank.requeueRemoteDeletes(pendingDeletes);
|
||
}
|
||
}
|
||
final bankSummary = await service.pushTransactions(
|
||
_bank.transactions,
|
||
progressStart: 0.82,
|
||
progressEnd: 1.0,
|
||
onProgress: _setProgress,
|
||
onStatus: _setStatus,
|
||
);
|
||
_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;
|
||
_pushMetaPending = false;
|
||
_pushFavouritesPending = false;
|
||
_pushHomeWidgetsPending = false;
|
||
_pushAppPrefsPending = false;
|
||
if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs) {
|
||
return;
|
||
}
|
||
|
||
_progressHold?.cancel();
|
||
_syncing = true;
|
||
_progressSuccess = false;
|
||
|
||
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…';
|
||
_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);
|
||
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();
|
||
}
|
||
|
||
_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;
|
||
_meta.onPersisted = null;
|
||
_prefs.onFavouritesPersisted = null;
|
||
_prefs.onHomeWidgetsPersisted = null;
|
||
_prefs.onAppPrefsPersisted = null;
|
||
super.dispose();
|
||
}
|
||
}
|