sync transactions with pocketbase 2
This commit is contained in:
@@ -155,6 +155,35 @@ class BankController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Union cloud txs into the local bank cache without dropping either side.
|
||||
///
|
||||
/// Rows land in the API cache; CSV imports that match are deduped away.
|
||||
Future<void> applySyncedTransactions(List<BankTransaction> fromCloud) async {
|
||||
if (fromCloud.isEmpty) return;
|
||||
|
||||
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud);
|
||||
_importedTransactions = filterNewTransactions(
|
||||
_remoteTransactions,
|
||||
_importedTransactions,
|
||||
);
|
||||
_invalidateMerged();
|
||||
|
||||
if (!_status.linked && _remoteTransactions.isNotEmpty) {
|
||||
_status = ConnectStatus(
|
||||
status: _status.status == 'disconnected' ? 'synced' : _status.status,
|
||||
linked: true,
|
||||
accountId: _status.accountId,
|
||||
sessionId: _status.sessionId,
|
||||
state: _status.state,
|
||||
validUntil: _status.validUntil,
|
||||
);
|
||||
}
|
||||
|
||||
await _persistCache();
|
||||
await _persistImported();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Load status; if linked, also load balance + transactions.
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,17 @@ class TxMetaController extends ChangeNotifier {
|
||||
return n;
|
||||
}
|
||||
|
||||
/// How many labeled txs get their labels only from merchant rules (no per-tx row).
|
||||
int merchantOnlyLabeledCount(List<BankTransaction> txs) {
|
||||
var n = 0;
|
||||
for (final tx in txs) {
|
||||
final stored = _byId[tx.id];
|
||||
if (stored != null && stored.hasLabel) continue;
|
||||
if (labelIdsFor(tx).isNotEmpty) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
List<TxLabel> get customLabels => List.unmodifiable(_customLabels);
|
||||
|
||||
/// Effective parent id (hierarchy map wins over embedded [TxLabel.parentId]).
|
||||
|
||||
Reference in New Issue
Block a user