Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s
Build APK / build (push) Successful in 5m27s
- Offline tab is now FilesView(offline: true) over a FolderBrowser interface (FilesController / OfflineController) sharing controls, tiles, thumbnails - Synced files follow the Files Cache rule (default: refresh every 15 min): per-account WorkManager jobs, in-app timer, resume and pull triggers; root-etag shortcut skips full walks when nothing changed - Single sync at a time (shared lock); logging out stops that account's sync - Sync safety: PROPFIND failures skip the path instead of deleting local files - Mirror empty folders and remove deleted ones; drop synced paths deleted on the server; missing local files are re-downloaded - Notifications: silent per-account sync notifications, "Background sync notifications" setting, audible upload/download completion - Files refresh in place (no spinner flash); retry after network returns - Fix sync badges not updating (shared native status stream), "Sync off" on launch (eager providers), Files Cache section moved under Device Sync Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../models/sync_status.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import 'files_controller.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// Device-sync state and settings: which remote paths (files or folders,
|
||||
@@ -16,13 +17,37 @@ import 'session_controller.dart';
|
||||
/// (`syncStatusFor`) despite living in an unrelated domain; now it's a
|
||||
/// second, focused provider those views read alongside their own tab
|
||||
/// controller.
|
||||
class SyncStatusController extends ChangeNotifier {
|
||||
///
|
||||
/// **Keeping synced files current.** Synced folders/files follow the same
|
||||
/// Files Cache rule (Settings) as folder listings, so nothing needs the
|
||||
/// "Sync now" button:
|
||||
///
|
||||
/// - *Refresh periodically (N min)*: a WorkManager periodic job runs in the
|
||||
/// background at that interval (Android's floor is 15 min, so shorter
|
||||
/// values only apply in the foreground), and while the app's open an
|
||||
/// in-app timer runs a pass every N minutes plus one on resume if it's
|
||||
/// been longer than that.
|
||||
/// - *Never cache*: no background schedule; a pass runs each time the app
|
||||
/// is opened/resumed.
|
||||
/// - *Refresh manually only*: only pull-to-refresh (any tab's sync header)
|
||||
/// and "Sync now".
|
||||
///
|
||||
/// Every automatic pass is cheap when nothing changed - the native worker
|
||||
/// checks each synced root's etag (Nextcloud propagates any descendant
|
||||
/// change up to every ancestor's etag) and skips the full walk if it's
|
||||
/// unchanged and the local copy is intact. There's no server push: Nextcloud's
|
||||
/// only push channels (the `notify_push` server app, or FCM via Nextcloud's
|
||||
/// push proxy) need server-side setup and a persistent connection/registered
|
||||
/// app identity, so this polls instead.
|
||||
class SyncStatusController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
final SessionController session;
|
||||
final FilesController files;
|
||||
|
||||
static const _prefSyncedPaths = 'ui_synced_folders';
|
||||
static const prefSyncedPaths = 'ui_synced_folders';
|
||||
static const _prefSyncedPathTypes = 'ui_synced_folder_types';
|
||||
static const _prefSyncEverything = 'ui_sync_everything';
|
||||
static const prefSyncEverything = 'ui_sync_everything';
|
||||
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
|
||||
static const _prefSyncNotifications = 'ui_sync_notifications';
|
||||
|
||||
List<String> _syncedPaths = [];
|
||||
// path -> isFolder, keyed the same as [_syncedPaths] - missing entries
|
||||
@@ -31,6 +56,7 @@ class SyncStatusController extends ChangeNotifier {
|
||||
Map<String, bool> _syncedPathTypes = {};
|
||||
bool _syncEverything = false;
|
||||
bool _syncOnCellular = false;
|
||||
bool _syncNotifications = true;
|
||||
|
||||
bool _isSyncingNow = false;
|
||||
Set<String> _syncingFileIds = {};
|
||||
@@ -38,9 +64,45 @@ class SyncStatusController extends ChangeNotifier {
|
||||
List<SyncConflictInfo> _syncConflicts = [];
|
||||
StreamSubscription<SyncStatusSnapshot>? _statusSub;
|
||||
|
||||
SyncStatusController(this.session) {
|
||||
// Gates addSyncedPaths/removeSyncedPaths (anything that persists
|
||||
// _syncedPaths) against running before _onAccountActivated's async
|
||||
// prefs load has actually finished - without this, syncing a folder
|
||||
// right after opening the app could race the load, overwrite the
|
||||
// persisted list with just the one path being added/removed, and
|
||||
// silently destroy every previously-synced folder. Deliberately starts
|
||||
// *incomplete*, not pre-completed - a mutation is only ever reachable
|
||||
// from Files/Offline UI, which requires a completed activation already
|
||||
// (Files' own item list depends on the same login event), so there's no
|
||||
// real deadlock risk, and starting complete would leave the very first
|
||||
// cold-start load completely unprotected (only account *switches*
|
||||
// re-arm it, via _onAccountCleared - the bug that actually caused
|
||||
// repeated data loss here).
|
||||
Completer<void> _accountLoadedGate = Completer<void>();
|
||||
|
||||
// True once _syncedPaths/_syncedPathTypes/_syncEverything have been
|
||||
// loaded from prefs for the *current* account - _onAccountActivated can
|
||||
// fire more than once per account (e.g. SessionController re-verifying
|
||||
// a provisional/offline login once connectivity returns), and a second
|
||||
// pass re-reading from storage could clobber an in-memory mutation made
|
||||
// between the first load and the second one if its own persist hadn't
|
||||
// landed yet. Reset in _onAccountCleared.
|
||||
bool _hasLoadedSyncedPathsForAccount = false;
|
||||
|
||||
Timer? _autoSyncTimer;
|
||||
bool _foreground = true;
|
||||
DateTime? _lastAutoSyncAt;
|
||||
CachePolicy? _lastPolicy;
|
||||
int? _lastInterval;
|
||||
|
||||
SyncStatusController(this.session, this.files) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
files.addListener(_onFilesChanged);
|
||||
session.addAccountClearedListener(_onAccountCleared);
|
||||
session.addAccountActivatedListener(_onAccountActivated);
|
||||
// `ready`, not `activated` - this controller's own activation work
|
||||
// (loading prefs, calling the native sync channel) is local/native
|
||||
// only, so it's safe to run even on a provisional/offline login (see
|
||||
// `SessionController.addAccountReadyListener`'s doc comment).
|
||||
session.addAccountReadyListener(_onAccountActivated);
|
||||
_statusSub = SyncService.statusStream.listen(_applySnapshot);
|
||||
_loadGlobalPrefs();
|
||||
}
|
||||
@@ -49,7 +111,21 @@ class SyncStatusController extends ChangeNotifier {
|
||||
bool get syncEverything => _syncEverything;
|
||||
bool get syncOnCellular => _syncOnCellular;
|
||||
|
||||
/// Whether background (automatic) sync runs may post progress/summary
|
||||
/// notifications. Conflicts and user-initiated "Sync now" always notify.
|
||||
bool get syncNotifications => _syncNotifications;
|
||||
|
||||
bool get isSyncingNow => _isSyncingNow;
|
||||
|
||||
bool get _hasSyncScope => _syncEverything || _syncedPaths.isNotEmpty;
|
||||
|
||||
/// How often the background WorkManager job should run, or null for no
|
||||
/// background sync at all - only the "refresh periodically" Files Cache
|
||||
/// policy schedules one (see the class doc comment).
|
||||
int? get backgroundSyncIntervalMinutes =>
|
||||
files.cachePolicy == CachePolicy.interval
|
||||
? files.cacheIntervalMinutes
|
||||
: null;
|
||||
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
|
||||
|
||||
/// How many of [syncedPaths] are folders (as opposed to individual
|
||||
@@ -57,11 +133,6 @@ class SyncStatusController extends ChangeNotifier {
|
||||
int get syncedFolderCount =>
|
||||
_syncedPaths.where((p) => _syncedPathTypes[p] ?? true).length;
|
||||
|
||||
/// How many individual files have actually been mirrored locally so far
|
||||
/// - distinct from [syncedFolderCount]/[syncedPaths], which are just the
|
||||
/// configured *targets*, not what's actually landed on disk yet.
|
||||
int get syncedItemCount => _syncedFileIds.length;
|
||||
|
||||
SyncHeaderStatus get syncHeaderStatus {
|
||||
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
|
||||
if (_isSyncingNow) return SyncHeaderStatus.syncing;
|
||||
@@ -121,10 +192,21 @@ class SyncStatusController extends ChangeNotifier {
|
||||
Future<void> _loadGlobalPrefs() async {
|
||||
final prefs = await session.prefsFuture;
|
||||
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
|
||||
_syncNotifications =
|
||||
prefs.getBool(_prefSyncNotifications) ?? _syncNotifications;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onAccountCleared() {
|
||||
// Re-armed (not completed) here, not just at construction - an
|
||||
// account switch means the *next* activation's load has to finish
|
||||
// before any mutation touching _syncedPaths is safe again. Only
|
||||
// replace the Completer if nothing's still awaiting the old one -
|
||||
// swapping it out from under a pending awaiter would orphan that
|
||||
// await forever, since nothing would ever complete the discarded
|
||||
// instance.
|
||||
if (_accountLoadedGate.isCompleted) _accountLoadedGate = Completer<void>();
|
||||
_hasLoadedSyncedPathsForAccount = false;
|
||||
_syncedPaths = [];
|
||||
_syncedPathTypes = {};
|
||||
_syncEverything = false;
|
||||
@@ -132,47 +214,175 @@ class SyncStatusController extends ChangeNotifier {
|
||||
_syncingFileIds = {};
|
||||
_syncedFileIds = {};
|
||||
_syncConflicts = [];
|
||||
_autoSyncTimer?.cancel();
|
||||
_lastAutoSyncAt = null;
|
||||
_lastPolicy = null;
|
||||
_lastInterval = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _onAccountActivated() async {
|
||||
final id = session.activeAccountId;
|
||||
if (id != null) {
|
||||
final prefs = await session.prefsFuture;
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
final pathsJson = prefs.getString(k(_prefSyncedPaths));
|
||||
if (pathsJson != null) {
|
||||
try {
|
||||
_syncedPaths = (jsonDecode(pathsJson) as List).cast<String>();
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Synced paths restore failed: $e');
|
||||
// Only load _syncedPaths/_syncedPathTypes/_syncEverything once per
|
||||
// account, not on every activation - this can fire more than once
|
||||
// for the same account (e.g. SessionController re-verifying a
|
||||
// provisional/offline login once connectivity returns), and a second
|
||||
// read from storage could clobber an in-memory mutation made between
|
||||
// the first load and this one if its own persist hadn't landed yet.
|
||||
// See _hasLoadedSyncedPathsForAccount's doc comment.
|
||||
if (id != null && !_hasLoadedSyncedPathsForAccount) {
|
||||
_hasLoadedSyncedPathsForAccount = true;
|
||||
// Wrapped so a single bad/mistyped stored pref value can't
|
||||
// propagate uncaught out of this method and skip the gate
|
||||
// completion below - that would leave every future
|
||||
// addSyncedPaths/removeSyncedPaths call awaiting a gate that never
|
||||
// completes, hanging forever. See FilesController.
|
||||
// _onAccountActivated's identical guard for the real incident this
|
||||
// mirrors.
|
||||
try {
|
||||
final prefs = await session.prefsFuture;
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
final pathsJson = prefs.getString(k(prefSyncedPaths));
|
||||
if (pathsJson != null) {
|
||||
try {
|
||||
_syncedPaths = (jsonDecode(pathsJson) as List).cast<String>();
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Synced paths restore failed: $e');
|
||||
_syncedPaths = [];
|
||||
}
|
||||
} else {
|
||||
_syncedPaths = [];
|
||||
}
|
||||
} else {
|
||||
_syncedPaths = [];
|
||||
}
|
||||
final typesJson = prefs.getString(k(_prefSyncedPathTypes));
|
||||
if (typesJson != null) {
|
||||
try {
|
||||
_syncedPathTypes = (jsonDecode(typesJson) as Map).map(
|
||||
(k, v) => MapEntry(k as String, v as bool),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Synced path types restore failed: $e');
|
||||
final typesJson = prefs.getString(k(_prefSyncedPathTypes));
|
||||
if (typesJson != null) {
|
||||
try {
|
||||
_syncedPathTypes = (jsonDecode(typesJson) as Map).map(
|
||||
(k, v) => MapEntry(k as String, v as bool),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Synced path types restore failed: $e');
|
||||
_syncedPathTypes = {};
|
||||
}
|
||||
} else {
|
||||
_syncedPathTypes = {};
|
||||
}
|
||||
} else {
|
||||
_syncedPathTypes = {};
|
||||
_syncEverything = prefs.getBool(k(prefSyncEverything)) ?? false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Account-activation prefs restore failed: $e');
|
||||
}
|
||||
_syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
// Only now is it safe for addSyncedPaths/removeSyncedPaths to mutate
|
||||
// and persist _syncedPaths - see _accountLoadedGate's doc comment.
|
||||
if (!_accountLoadedGate.isCompleted) _accountLoadedGate.complete();
|
||||
unawaited(SyncService.getStatus(id).then(_applySnapshot));
|
||||
|
||||
// Scheduling depends on the Files Cache policy, so wait for it to load
|
||||
// rather than scheduling from the defaults and correcting a moment later.
|
||||
await files.displayPrefsLoaded;
|
||||
_lastPolicy = files.cachePolicy;
|
||||
_lastInterval = files.cacheIntervalMinutes;
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
unawaited(SyncService.getStatus().then(_applySnapshot));
|
||||
_restartAutoSyncTimer();
|
||||
if (_dueForAutoSync) unawaited(_autoSync());
|
||||
}
|
||||
|
||||
// ---- Automatic sync (see the class doc comment) ----
|
||||
|
||||
void _onFilesChanged() {
|
||||
// FilesController notifies on every navigation - only the cache rule
|
||||
// itself matters here.
|
||||
if (_lastPolicy == null) return; // account not activated yet
|
||||
final policy = files.cachePolicy;
|
||||
final interval = files.cacheIntervalMinutes;
|
||||
if (policy == _lastPolicy && interval == _lastInterval) return;
|
||||
_lastPolicy = policy;
|
||||
_lastInterval = interval;
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
_restartAutoSyncTimer();
|
||||
}
|
||||
|
||||
/// Whether an app-open/resume should kick off a pass under the current
|
||||
/// cache rule.
|
||||
bool get _dueForAutoSync {
|
||||
switch (files.cachePolicy) {
|
||||
case CachePolicy.never:
|
||||
return true;
|
||||
case CachePolicy.manual:
|
||||
return false;
|
||||
case CachePolicy.interval:
|
||||
final last = _lastAutoSyncAt;
|
||||
return last == null ||
|
||||
DateTime.now().difference(last) >=
|
||||
Duration(minutes: files.cacheIntervalMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
void _restartAutoSyncTimer() {
|
||||
_autoSyncTimer?.cancel();
|
||||
if (!_foreground ||
|
||||
!_hasSyncScope ||
|
||||
files.cachePolicy != CachePolicy.interval) {
|
||||
return;
|
||||
}
|
||||
_autoSyncTimer = Timer.periodic(
|
||||
Duration(minutes: files.cacheIntervalMinutes),
|
||||
(_) => unawaited(_autoSync()),
|
||||
);
|
||||
}
|
||||
|
||||
/// A quiet, network-setting-respecting pass - see [SyncService.syncNow].
|
||||
Future<void> _autoSync() async {
|
||||
if (!_canSyncQuietly) return;
|
||||
_lastAutoSyncAt = DateTime.now();
|
||||
try {
|
||||
await SyncService.syncNow(
|
||||
session,
|
||||
this,
|
||||
force: false,
|
||||
respectNetworkSetting: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Automatic sync failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// A deliberate pull-to-refresh (any tab's sync header): check for server
|
||||
/// changes now, regardless of the cache rule or the Wi-Fi-only setting.
|
||||
Future<void> syncOnPull() async {
|
||||
if (!_canSyncQuietly) return;
|
||||
try {
|
||||
await SyncService.syncNow(session, this, force: false);
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Pull-triggered sync failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSyncQuietly =>
|
||||
_hasSyncScope &&
|
||||
!_isSyncingNow &&
|
||||
session.isLoggedIn &&
|
||||
!session.connectivity.isOffline;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_foreground = true;
|
||||
_restartAutoSyncTimer();
|
||||
if (_dueForAutoSync) unawaited(_autoSync());
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
// The in-app timer only runs while foregrounded; WorkManager owns the
|
||||
// background schedule.
|
||||
_foreground = false;
|
||||
_autoSyncTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _applySnapshot(SyncStatusSnapshot snapshot) {
|
||||
if (snapshot.accountId != null &&
|
||||
// A null accountId is the bus's initial "nothing has synced in this
|
||||
// process yet" emission on subscribe - applying it would wipe the
|
||||
// synced-file ids seeded by getStatus(accountId).
|
||||
if (snapshot.accountId == null ||
|
||||
snapshot.accountId != session.activeAccountId) {
|
||||
return;
|
||||
}
|
||||
@@ -181,6 +391,11 @@ class SyncStatusController extends ChangeNotifier {
|
||||
_syncedFileIds = snapshot.syncedFileIds;
|
||||
_syncConflicts = snapshot.conflicts;
|
||||
notifyListeners();
|
||||
|
||||
// A synced file/folder deleted on the server has nothing left to sync -
|
||||
// drop it from the synced list rather than leaving a dead entry.
|
||||
final gone = snapshot.missingRoots.where(_syncedPaths.contains).toList();
|
||||
if (gone.isNotEmpty) unawaited(removeSyncedPaths(gone));
|
||||
}
|
||||
|
||||
void _persistSyncedPaths() {
|
||||
@@ -188,7 +403,7 @@ class SyncStatusController extends ChangeNotifier {
|
||||
if (id == null) return;
|
||||
session.prefsFuture.then((p) {
|
||||
p.setString(
|
||||
session.accountStore.accountPrefKey(id, _prefSyncedPaths),
|
||||
session.accountStore.accountPrefKey(id, prefSyncedPaths),
|
||||
jsonEncode(_syncedPaths),
|
||||
);
|
||||
p.setString(
|
||||
@@ -200,26 +415,74 @@ class SyncStatusController extends ChangeNotifier {
|
||||
|
||||
bool isPathSynced(String path) => _syncedPaths.contains(path);
|
||||
|
||||
void addSyncedPath(String path, {required bool isFolder}) {
|
||||
if (_syncedPaths.contains(path)) return;
|
||||
_syncedPaths = [..._syncedPaths, path];
|
||||
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
|
||||
Future<void> addSyncedPath(String path, {required bool isFolder}) {
|
||||
return addSyncedPaths({path: isFolder});
|
||||
}
|
||||
|
||||
/// Adds every path in [entries] (path -> isFolder) to sync in one go -
|
||||
/// unlike calling [addSyncedPath] once per item in a loop, this touches
|
||||
/// `_syncedPaths`/persists/reschedules/triggers an immediate sync
|
||||
/// exactly once no matter how many paths are being added. Looping
|
||||
/// `addSyncedPath` from a multi-select "Sync to device" was the actual
|
||||
/// bug behind only the first selected item ever actually syncing: each
|
||||
/// call fired its own `SyncService.syncNow`, and WorkManager's
|
||||
/// `enqueueUniqueWork(..., ExistingWorkPolicy.REPLACE, ...)` policy
|
||||
/// meant each later call cancelled the previous item's still-in-flight
|
||||
/// sync pass (and could stomp on its `SyncEngine.saveState`, which
|
||||
/// overwrites the whole state map rather than merging) instead of
|
||||
/// letting it finish.
|
||||
Future<void> addSyncedPaths(Map<String, bool> entries) async {
|
||||
// Must not mutate/persist _syncedPaths before the per-account load
|
||||
// has actually populated it - see _accountLoadedGate's doc comment.
|
||||
await _accountLoadedGate.future;
|
||||
var changed = false;
|
||||
entries.forEach((path, isFolder) {
|
||||
if (_syncedPaths.contains(path)) return;
|
||||
_syncedPaths = [..._syncedPaths, path];
|
||||
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
|
||||
changed = true;
|
||||
});
|
||||
if (!changed) return;
|
||||
notifyListeners();
|
||||
_persistSyncedPaths();
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
_restartAutoSyncTimer();
|
||||
// `reschedule` alone only lines up the *periodic* job, which can be up
|
||||
// to an hour away from its first run - without an immediate one-off
|
||||
// sync here too, newly-added items would just sit unsynced until the
|
||||
// user thought to trigger one manually (e.g. pulling to refresh on
|
||||
// the Offline tab).
|
||||
unawaited(_syncNowSilently());
|
||||
}
|
||||
|
||||
/// Unsyncs [path] and deletes its already-downloaded local mirror (see
|
||||
/// `SyncService.removeLocalSync`) - stopping sync alone would leave
|
||||
/// whatever had already been downloaded sitting on disk indefinitely.
|
||||
void removeSyncedPath(String path) {
|
||||
if (!_syncedPaths.contains(path)) return;
|
||||
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
|
||||
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
|
||||
Future<void> removeSyncedPath(String path) {
|
||||
return removeSyncedPaths([path]);
|
||||
}
|
||||
|
||||
/// Batched counterpart to [removeSyncedPath] - see [addSyncedPaths]'s
|
||||
/// doc comment for why a multi-item loop of individual calls is unsafe
|
||||
/// for `reschedule`/native side effects, even though removal itself
|
||||
/// (unlike adding) doesn't trigger a one-off sync to race.
|
||||
Future<void> removeSyncedPaths(List<String> paths) async {
|
||||
await _accountLoadedGate.future;
|
||||
var changed = false;
|
||||
for (final path in paths) {
|
||||
if (!_syncedPaths.contains(path)) continue;
|
||||
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
|
||||
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
notifyListeners();
|
||||
_persistSyncedPaths();
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
unawaited(SyncService.removeLocalSync(session, path));
|
||||
_restartAutoSyncTimer();
|
||||
for (final path in paths) {
|
||||
unawaited(SyncService.removeLocalSync(session, path));
|
||||
}
|
||||
}
|
||||
|
||||
void setSyncEverything(bool value) {
|
||||
@@ -230,12 +493,24 @@ class SyncStatusController extends ChangeNotifier {
|
||||
if (id != null) {
|
||||
session.prefsFuture.then(
|
||||
(p) => p.setBool(
|
||||
session.accountStore.accountPrefKey(id, _prefSyncEverything),
|
||||
session.accountStore.accountPrefKey(id, prefSyncEverything),
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
_restartAutoSyncTimer();
|
||||
if (value) unawaited(_syncNowSilently());
|
||||
}
|
||||
|
||||
void setSyncNotifications(bool value) {
|
||||
if (_syncNotifications == value) return;
|
||||
_syncNotifications = value;
|
||||
notifyListeners();
|
||||
session.prefsFuture.then((p) => p.setBool(_prefSyncNotifications, value));
|
||||
// Baked into each periodic job's input, so the jobs have to be
|
||||
// re-enqueued for the change to reach background runs.
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
}
|
||||
|
||||
void setSyncOnCellular(bool value) {
|
||||
@@ -246,6 +521,18 @@ class SyncStatusController extends ChangeNotifier {
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
}
|
||||
|
||||
/// Fire-and-forget immediate sync, swallowing errors - called right
|
||||
/// after turning sync on for something (see [addSyncedPath]/
|
||||
/// [setSyncEverything]) so it actually starts syncing now instead of
|
||||
/// only ever running on the next periodic pass or a manual "Sync now".
|
||||
Future<void> _syncNowSilently() async {
|
||||
try {
|
||||
await SyncService.syncNow(session, this);
|
||||
} catch (e) {
|
||||
debugPrint('[SyncStatusController] Immediate sync failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// In-app conflict resolution (the sync header's "Keep local"/"Use
|
||||
/// server" buttons) - see `SyncService.resolveConflict`'s doc comment
|
||||
/// for why this shares the exact same native path the notification
|
||||
@@ -277,6 +564,9 @@ class SyncStatusController extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
files.removeListener(_onFilesChanged);
|
||||
_autoSyncTimer?.cancel();
|
||||
_statusSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user