Fix device sync: folder status badges, remove-deletes-local, multi-select, header summary
Build APK / build (push) Successful in 5m35s
Build APK / build (push) Successful in 5m35s
- Folders now show the synced status badge themselves, not just their nested files - device-sync state never tracked folders individually, so syncStatusFor derives a folder's badge from whether its path falls within sync scope instead. - Turning sync off for a path now deletes its local mirror and clears its native sync-state entries (SyncEngine.removeLocalSync), instead of just stopping future updates and leaving the downloaded copy behind. - "Sync to device" in Files' selection toolbar now works over the whole selection at once instead of being gated to a single selected item. - The sync header's expanded panel shows real folder/item counts instead of a static "Synced" label that stayed accurate-sounding even before anything had actually synced. - Confirmation SnackBars on starting/stopping sync from both the selection toolbar and Settings' Device Sync card. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,10 +20,15 @@ class SyncStatusController extends ChangeNotifier {
|
||||
final SessionController session;
|
||||
|
||||
static const _prefSyncedPaths = 'ui_synced_folders';
|
||||
static const _prefSyncedPathTypes = 'ui_synced_folder_types';
|
||||
static const _prefSyncEverything = 'ui_sync_everything';
|
||||
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
|
||||
|
||||
List<String> _syncedPaths = [];
|
||||
// path -> isFolder, keyed the same as [_syncedPaths] - missing entries
|
||||
// (from before this map existed) default to folder, the overwhelmingly
|
||||
// common case.
|
||||
Map<String, bool> _syncedPathTypes = {};
|
||||
bool _syncEverything = false;
|
||||
bool _syncOnCellular = false;
|
||||
|
||||
@@ -47,6 +52,16 @@ class SyncStatusController extends ChangeNotifier {
|
||||
bool get isSyncingNow => _isSyncingNow;
|
||||
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
|
||||
|
||||
/// How many of [syncedPaths] are folders (as opposed to individual
|
||||
/// files) - used by the sync header's expanded summary.
|
||||
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;
|
||||
@@ -54,7 +69,47 @@ class SyncStatusController extends ChangeNotifier {
|
||||
return SyncHeaderStatus.done;
|
||||
}
|
||||
|
||||
/// True if [path] itself, or an ancestor of it, is covered by device
|
||||
/// sync - either explicitly (one of [_syncedPaths]) or via
|
||||
/// [_syncEverything]. Shared by [syncStatusFor] (folders) and
|
||||
/// [localSyncedFilePath] (files).
|
||||
bool _isPathInSyncScope(String path) {
|
||||
if (_syncEverything) return true;
|
||||
final normalized = path.endsWith('/')
|
||||
? path.substring(0, path.length - 1)
|
||||
: path;
|
||||
return _syncedPaths.any((folder) {
|
||||
final f = folder.endsWith('/')
|
||||
? folder.substring(0, folder.length - 1)
|
||||
: folder;
|
||||
return normalized == f || normalized.startsWith('$f/');
|
||||
});
|
||||
}
|
||||
|
||||
/// True if any pending conflict falls under [folderPath].
|
||||
bool _folderHasConflict(String folderPath) {
|
||||
final normalized = folderPath.endsWith('/')
|
||||
? folderPath.substring(0, folderPath.length - 1)
|
||||
: folderPath;
|
||||
return _syncConflicts.any((c) {
|
||||
final p = c.remotePath.endsWith('/')
|
||||
? c.remotePath.substring(0, c.remotePath.length - 1)
|
||||
: c.remotePath;
|
||||
return p == normalized || p.startsWith('$normalized/');
|
||||
});
|
||||
}
|
||||
|
||||
/// Folders don't get their own entry in [_syncedFileIds] (only individual
|
||||
/// files do - see `SyncEngine.diffFolder`'s `if (entry.isFolder) continue`),
|
||||
/// so a folder's status is derived from whether it's in sync scope at all
|
||||
/// rather than tracked per-item like a file's is.
|
||||
SyncItemStatus syncStatusFor(NextcloudItem item) {
|
||||
if (item.isFolder) {
|
||||
if (!_isPathInSyncScope(item.path)) return SyncItemStatus.none;
|
||||
if (_folderHasConflict(item.path)) return SyncItemStatus.conflict;
|
||||
if (_isSyncingNow) return SyncItemStatus.syncing;
|
||||
return SyncItemStatus.synced;
|
||||
}
|
||||
if (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing;
|
||||
if (_syncConflicts.any((c) => c.fileId == item.id)) {
|
||||
return SyncItemStatus.conflict;
|
||||
@@ -71,6 +126,7 @@ class SyncStatusController extends ChangeNotifier {
|
||||
|
||||
void _onAccountCleared() {
|
||||
_syncedPaths = [];
|
||||
_syncedPathTypes = {};
|
||||
_syncEverything = false;
|
||||
_isSyncingNow = false;
|
||||
_syncingFileIds = {};
|
||||
@@ -95,6 +151,19 @@ class SyncStatusController extends ChangeNotifier {
|
||||
} 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');
|
||||
_syncedPathTypes = {};
|
||||
}
|
||||
} else {
|
||||
_syncedPathTypes = {};
|
||||
}
|
||||
_syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -117,30 +186,40 @@ class SyncStatusController extends ChangeNotifier {
|
||||
void _persistSyncedPaths() {
|
||||
final id = session.activeAccountId;
|
||||
if (id == null) return;
|
||||
session.prefsFuture.then(
|
||||
(p) => p.setString(
|
||||
session.prefsFuture.then((p) {
|
||||
p.setString(
|
||||
session.accountStore.accountPrefKey(id, _prefSyncedPaths),
|
||||
jsonEncode(_syncedPaths),
|
||||
),
|
||||
);
|
||||
);
|
||||
p.setString(
|
||||
session.accountStore.accountPrefKey(id, _prefSyncedPathTypes),
|
||||
jsonEncode(_syncedPathTypes),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bool isPathSynced(String path) => _syncedPaths.contains(path);
|
||||
|
||||
void addSyncedPath(String path) {
|
||||
void addSyncedPath(String path, {required bool isFolder}) {
|
||||
if (_syncedPaths.contains(path)) return;
|
||||
_syncedPaths = [..._syncedPaths, path];
|
||||
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
|
||||
notifyListeners();
|
||||
_persistSyncedPaths();
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
notifyListeners();
|
||||
_persistSyncedPaths();
|
||||
unawaited(SyncService.reschedule(session, this));
|
||||
unawaited(SyncService.removeLocalSync(session, path));
|
||||
}
|
||||
|
||||
void setSyncEverything(bool value) {
|
||||
@@ -192,18 +271,7 @@ class SyncStatusController extends ChangeNotifier {
|
||||
Future<String?> localSyncedFilePath(NextcloudItem item) async {
|
||||
final id = session.activeAccountId;
|
||||
if (id == null) return null;
|
||||
final itemPath = item.path.endsWith('/')
|
||||
? item.path.substring(0, item.path.length - 1)
|
||||
: item.path;
|
||||
final isSynced =
|
||||
_syncEverything ||
|
||||
_syncedPaths.any((folder) {
|
||||
final f = folder.endsWith('/')
|
||||
? folder.substring(0, folder.length - 1)
|
||||
: folder;
|
||||
return itemPath == f || itemPath.startsWith('$f/');
|
||||
});
|
||||
if (!isSynced) return null;
|
||||
if (!_isPathInSyncScope(item.path)) return null;
|
||||
return SyncService.localSyncedFilePath(id, item.path);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,23 @@ class SyncService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Deletes [path]'s local mirror and its recorded sync state - called
|
||||
/// when the user turns sync off for a path, so the on-device copy
|
||||
/// actually goes away instead of just stopping future updates. See
|
||||
/// `SyncEngine.removeLocalSync`'s doc comment for why the state also has
|
||||
/// to be cleared, not just the files.
|
||||
static Future<void> removeLocalSync(
|
||||
SessionController session,
|
||||
String path,
|
||||
) async {
|
||||
final accountId = session.activeAccountId;
|
||||
if (accountId == null) return;
|
||||
await _channel.invokeMethod('removeLocalSync', {
|
||||
'accountId': accountId,
|
||||
'path': path,
|
||||
});
|
||||
}
|
||||
|
||||
/// The deterministic local mirror path for [remoteItemPath] under
|
||||
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
||||
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
||||
|
||||
@@ -823,7 +823,15 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: 'Stop syncing',
|
||||
onPressed: () => sync.removeSyncedPath(folder),
|
||||
onPressed: () {
|
||||
sync.removeSyncedPath(folder);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Removed from device sync'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
|
||||
+43
-12
@@ -292,18 +292,15 @@ class _FilesViewState extends State<FilesView>
|
||||
label: 'Rename',
|
||||
onTap: () => _renameItem(selected.single),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: sync.isPathSynced(selected.single.path)
|
||||
? Icons.sync_rounded
|
||||
: Icons.sync_outlined,
|
||||
label: sync.isPathSynced(selected.single.path)
|
||||
? 'Stop syncing to device'
|
||||
: 'Sync to device',
|
||||
onTap: () => sync.isPathSynced(selected.single.path)
|
||||
? sync.removeSyncedPath(selected.single.path)
|
||||
: sync.addSyncedPath(selected.single.path),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: selected.every((i) => sync.isPathSynced(i.path))
|
||||
? Icons.sync_rounded
|
||||
: Icons.sync_outlined,
|
||||
label: selected.every((i) => sync.isPathSynced(i.path))
|
||||
? 'Stop syncing to device'
|
||||
: 'Sync to device',
|
||||
onTap: () => _toggleSyncSelected(context, sync, selected),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: Icons.info_outline_rounded,
|
||||
@@ -807,6 +804,40 @@ class _FilesViewState extends State<FilesView>
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
/// Toggles device sync for every selected item at once - if they're all
|
||||
/// already synced this stops syncing all of them (and deletes their local
|
||||
/// mirrors, see `SyncStatusController.removeSyncedPath`), otherwise it
|
||||
/// starts syncing whichever ones aren't synced yet.
|
||||
void _toggleSyncSelected(
|
||||
BuildContext context,
|
||||
SyncStatusController sync,
|
||||
List<NextcloudItem> items,
|
||||
) {
|
||||
final allSynced = items.every((i) => sync.isPathSynced(i.path));
|
||||
if (allSynced) {
|
||||
for (final item in items) {
|
||||
sync.removeSyncedPath(item.path);
|
||||
}
|
||||
} else {
|
||||
for (final item in items) {
|
||||
if (!sync.isPathSynced(item.path)) {
|
||||
sync.addSyncedPath(item.path, isFolder: item.isFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
_clearSelection();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
allSynced
|
||||
? 'Removed ${items.length} item(s) from device sync'
|
||||
: 'Syncing ${items.length} item(s) to this device',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hands the whole batch off to `DownloadService.kt` (see its doc
|
||||
/// comment) rather than downloading each item in Dart then prompting
|
||||
/// `file_saver` per file - same reasoning as `UploadService`/
|
||||
|
||||
@@ -21,6 +21,27 @@ import '../providers/sync_status_controller.dart';
|
||||
};
|
||||
}
|
||||
|
||||
/// The expanded sync panel's headline - actual folder/item counts instead
|
||||
/// of just repeating the compact chip's generic "Synced" label, which reads
|
||||
/// as true even when nothing has actually finished syncing yet (e.g. right
|
||||
/// after adding a folder, before the first pass completes).
|
||||
String _syncSummary(SyncStatusController sync) {
|
||||
if (sync.syncConflicts.isNotEmpty) return 'Sync issue';
|
||||
if (sync.isSyncingNow) return 'Syncing…';
|
||||
final items = sync.syncedItemCount;
|
||||
if (sync.syncEverything) {
|
||||
return items > 0 ? '$items item${items == 1 ? '' : 's'} synced' : 'Sync off';
|
||||
}
|
||||
final folders = sync.syncedFolderCount;
|
||||
if (folders == 0) return 'Sync off';
|
||||
final folderWord = folders == 1 ? 'folder' : 'folders';
|
||||
if (items == 0) {
|
||||
return '$folders $folderWord selected — nothing synced yet';
|
||||
}
|
||||
final itemWord = items == 1 ? 'item' : 'items';
|
||||
return '$folders $folderWord & $items $itemWord synced';
|
||||
}
|
||||
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
@@ -361,8 +382,7 @@ class _SyncedStretchPanel extends StatelessWidget {
|
||||
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
|
||||
// before the 100px lock threshold.
|
||||
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
|
||||
final status = syncStatus.syncHeaderStatus;
|
||||
final (_, statusLabel) = _syncHeaderDisplay(status);
|
||||
final statusLabel = _syncSummary(syncStatus);
|
||||
final conflicts = syncStatus.syncConflicts;
|
||||
|
||||
return Stack(
|
||||
|
||||
Reference in New Issue
Block a user