Files
ingtrakapp/lib/providers/sync_provider.dart
T

291 lines
7.9 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 '../services/pb_sync_service.dart';
import 'auth_provider.dart';
import 'bank_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,
}) : _auth = auth,
_meta = meta,
_bank = bank {
_wasLoggedIn = auth.isLoggedIn;
_meta.onPersisted = _schedulePush;
_auth.addListener(_onAuthChanged);
if (_auth.isLoggedIn) {
unawaited(syncNow());
}
}
final AuthController _auth;
final TxMetaController _meta;
final BankController _bank;
bool _wasLoggedIn = false;
bool _syncing = false;
bool _pushQueued = false;
bool _queuedFullSync = false;
Timer? _pushDebounce;
Timer? _progressHold;
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;
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 _announce(String message, {bool isProgress = false, bool isError = false}) {
_feedbackEpoch++;
_feedback = SyncFeedback(
epoch: _feedbackEpoch,
message: message,
isProgress: isProgress,
isError: isError,
);
notifyListeners();
}
void _setProgress(double value, {bool success = false}) {
_syncProgress = value.clamp(0.0, 1.0);
_showProgressRing = true;
_progressSuccess = success;
notifyListeners();
}
void _setStatus(String message) {
if (_statusDetail == message) return;
_statusDetail = message;
notifyListeners();
}
void _finishProgress({required bool success}) {
_progressHold?.cancel();
_statusDetail = '';
if (success) {
_setProgress(1, success: true);
_progressHold = Timer(const Duration(milliseconds: 900), () {
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
notifyListeners();
});
} else {
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
notifyListeners();
}
}
void _onAuthChanged() {
final loggedIn = _auth.isLoggedIn;
if (loggedIn && !_wasLoggedIn) {
unawaited(syncNow());
}
_wasLoggedIn = loggedIn;
if (!loggedIn) {
_pushDebounce?.cancel();
_progressHold?.cancel();
_lastError = null;
_statusDetail = '';
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
notifyListeners();
}
}
void _schedulePush() {
if (!_auth.isLoggedIn) 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);
await _meta.replaceStore(mergedMeta);
await _bank.applySyncedTransactions(remoteTxs);
_setProgress(0.55);
_setStatus('Pushing…');
final metaSummary = await service.push(
mergedMeta,
progressStart: 0.55,
progressEnd: 0.82,
onProgress: _setProgress,
onStatus: _setStatus,
);
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());
}
}
}
/// Annotation-only push after local meta edits (avoids re-uploading 4k+ txs).
Future<void> _pushOnly() async {
if (!_auth.isLoggedIn || _syncing) {
if (_syncing) _pushQueued = true;
return;
}
_progressHold?.cancel();
_syncing = true;
_progressSuccess = false;
_statusDetail = 'Pushing…';
_setProgress(0.05);
_announce('Syncing…', isProgress: true);
try {
final service = PbSyncService(_auth.client);
final summary = await service.push(
_meta.exportStore(),
progressStart: 0.05,
progressEnd: 1.0,
onProgress: _setProgress,
onStatus: _setStatus,
);
_lastSyncedAt = DateTime.now();
_lastError = null;
_lastSyncSummary = summary;
_announce('Sync complete');
_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();
_auth.removeListener(_onAuthChanged);
_meta.onPersisted = null;
super.dispose();
}
}