sync transactions with pocketbase 2

This commit is contained in:
davmar
2026-09-09 22:31:04 +02:00
parent 96627a830e
commit e35a4d6893
12 changed files with 1442 additions and 12 deletions
+290
View File
@@ -0,0 +1,290 @@
/// 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();
}
}