Fix device sync: folder status badges, remove-deletes-local, multi-select, header summary
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:
2026-09-20 17:20:14 -04:00
co-authored by Claude Sonnet 5
parent ff5f04e9a1
commit 49ae712f5e
9 changed files with 273 additions and 51 deletions
+42 -17
View File
@@ -306,7 +306,15 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
chip/panel, replacing what used to be the WebDAV-refresh-loading chip/panel, replacing what used to be the WebDAV-refresh-loading
indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict
- drives the small corner badge on Files' tiles, `SyncStatusBadge`) are - drives the small corner badge on Files' tiles, `SyncStatusBadge`) are
both computed from this state, not fetched per-item. both computed from this state, not fetched per-item. Folders never get
their own entry in the native state map (`diffFolder` only ever tracks
individual files - `if (entry.isFolder) continue`), so `syncStatusFor`
derives a folder's badge differently than a file's: `synced` once the
folder's own path (or an ancestor of it) is in sync scope
(`_isPathInSyncScope`, shared with `localSyncedFilePath`), `syncing`
while any sync pass is running, `conflict` if a pending conflict's
`remotePath` falls under it - not from `syncedFileIds`, which only ever
contains individual files.
- **In-app conflict resolution reuses the exact same enqueue path as the - **In-app conflict resolution reuses the exact same enqueue path as the
notification actions** - `ConflictResolveWorker.enqueue(...)` is a notification actions** - `ConflictResolveWorker.enqueue(...)` is a
shared companion function; `SyncConflictReceiver` (the notification shared companion function; `SyncConflictReceiver` (the notification
@@ -326,31 +334,48 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
to actually push the local copy up or pull the server copy down and to actually push the local copy up or pull the server copy down and
refresh that file's recorded state. refresh that file's recorded state.
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel - `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
(`reschedule`/`cancel`/`syncNow`) is the only bridge from Dart: a periodic (`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge
`WorkRequest`'s input `Data` and `Constraints` are fixed at enqueue time, from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are
so changing the synced-folder list, the active account, or the Wi-Fi-only fixed at enqueue time, so changing the synced-folder list, the active
setting means cancelling and re-enqueueing, not updating in place. account, or the Wi-Fi-only setting means cancelling and re-enqueueing,
[`SyncService`](../../lib/services/sync_service.dart) (Dart) wraps this - not updating in place. [`SyncService`](../../lib/services/sync_service.dart)
`SyncStatusController` calls `SyncService.reschedule` after every (Dart) wraps this - `SyncStatusController` calls `SyncService.reschedule`
successful login/account switch and every synced-folder/`syncOnCellular` after every successful login/account switch and every synced-folder/
change, and `SyncService.cancel` on logout/last-account-removed. The `syncOnCellular` change, and `SyncService.cancel` on logout/last-account-
network constraint is `NetworkType.UNMETERED` by default removed. The network constraint is `NetworkType.UNMETERED` by default
(`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's (`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's
opted into cellular sync. opted into cellular sync. Turning sync off for a path
(`SyncStatusController.removeSyncedPath`) also calls
`SyncService.removeLocalSync`, which runs `SyncEngine.removeLocalSync` on
a background thread (deletes the local mirror files under that path
*and* their entries in the native sync-state map - clearing state too,
not just the files, matters because a bare "file's gone but the server
hasn't changed" without a state reset reads as a user-initiated local
deletion to `diffFolder`, so a later re-add wouldn't re-download
anything).
- Synced-path list (`SyncStatusController.syncedPaths` - files or folders, not - Synced-path list (`SyncStatusController.syncedPaths` - files or folders, not
just folders despite the name of the underlying pref/native `Data` key, just folders despite the name of the underlying pref/native `Data` key,
which stayed `ui_synced_folders`/`folders` to avoid a storage-key which stayed `ui_synced_folders`/`folders` to avoid a storage-key
migration for a rename) follows the standard per-account-pref pattern migration for a rename) follows the standard per-account-pref pattern
(JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); so does (JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); a
`syncEverything` (`ui_sync_everything`, per account) - when on, parallel `_syncedPathTypes` map (path -> isFolder, `ui_synced_folder_types`)
`SyncService` sends `['/']` as the path list instead of `syncedPaths`, tracks which of those paths are folders vs individual files for the sync
mirroring the whole account rather than requiring per-item opt-in. header's folder/item counts, defaulting missing entries to folder (the
`syncOnCellular` is a plain global pref. All three are managed from common case, and what any path added before this map existed will look
like). `syncEverything` (`ui_sync_everything`, per account) works the
same way - when on, `SyncService` sends `['/']` as the path list instead
of `syncedPaths`, mirroring the whole account rather than requiring
per-item opt-in. `syncOnCellular` is a plain global pref. All three are
managed from
Settings → Device Sync (a "Sync everything" switch, the path list with Settings → Device Sync (a "Sync everything" switch, the path list with
remove buttons - hidden while "Sync everything" is on - the cellular remove buttons - hidden while "Sync everything" is on - the cellular
toggle, and a manual "Sync now"); individual files or folders are toggle, and a manual "Sync now"); individual files or folders are
additionally toggled from Files' selection toolbar ("Sync to device", additionally toggled from Files' selection toolbar ("Sync to device",
single-selection, either item type). works over the whole selection at once - either item type, folders or
files - not just a single item; the action reads as "stop syncing" only
once every selected item is already synced, otherwise it syncs whichever
ones aren't yet, and either direction ends with a confirmation
SnackBar).
- **`android/app/proguard-rules.pro` exists specifically for this feature, - **`android/app/proguard-rules.pro` exists specifically for this feature,
and keeps `androidx.work.**` wholesale rather than naming individual and keeps `androidx.work.**` wholesale rather than naming individual
classes.** Flutter's own Gradle plugin auto-enables R8 minification for classes.** Flutter's own Gradle plugin auto-enables R8 minification for
+7 -2
View File
@@ -63,12 +63,17 @@ widgets. Key points:
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
`architecture.md`); also where the pull-to-refresh gesture thresholds and `architecture.md`); also where the pull-to-refresh gesture thresholds and
the classic Material refresh spinner live. Its persistent chip/panel the classic Material refresh spinner live. Its persistent compact chip
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync (icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync
status (`SyncStatusController.syncHeaderStatus`), not the WebDAV-refresh status (`SyncStatusController.syncHeaderStatus`), not the WebDAV-refresh
loading state the pull gesture itself triggers - that has its own, loading state the pull gesture itself triggers - that has its own,
separate floating spinner bubble, so nothing was lost by handing the separate floating spinner bubble, so nothing was lost by handing the
persistent text/icon over. persistent text/icon over. The expanded panel's headline is a separate,
more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) -
actual folder/item counts ("2 folders & 5 items synced") rather than
just repeating the chip's generic label, which would otherwise read
"Synced" even when a folder's just been added and nothing's downloaded
yet.
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small - [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small
corner badge over a thumbnail showing per-item device-sync status corner badge over a thumbnail showing per-item device-sync status
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files' (`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files'
@@ -136,6 +136,7 @@ class MainActivity : FlutterFragmentActivity() {
"syncNow" -> syncNow(call, result) "syncNow" -> syncNow(call, result)
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot())) "getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
"resolveConflict" -> resolveConflict(call, result) "resolveConflict" -> resolveConflict(call, result)
"removeLocalSync" -> removeLocalSync(call, result)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -405,6 +406,24 @@ class MainActivity : FlutterFragmentActivity() {
result.success(null) result.success(null)
} }
/// Deletes a path's local mirror once the user turns sync off for it -
/// see [SyncEngine.removeLocalSync]. Runs off the main thread since it
/// walks/deletes a directory tree; `result.success` is posted back via
/// [mainHandler] since MethodChannel results must be delivered on the
/// platform thread.
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
val path = call.argument<String>("path")
if (accountId == null || path == null) {
result.error("bad_args", "Missing required arguments", null)
return
}
Thread {
SyncEngine.removeLocalSync(applicationContext, accountId, path)
mainHandler.post { result.success(null) }
}.start()
}
/// One-off immediate run (Settings' "Sync now"), independent of the /// One-off immediate run (Settings' "Sync now"), independent of the
/// periodic schedule. /// periodic schedule.
private fun syncNow(call: MethodCall, result: MethodChannel.Result) { private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
@@ -356,6 +356,35 @@ object SyncEngine {
return actions return actions
} }
/**
* Deletes [path]'s local mirror (a single file, or a whole folder's
* worth of files) and removes its entries from the persisted sync-state
* map, called when the user turns sync off for that path - without
* clearing the state too, a later re-add would see the (now missing)
* local file as "deleted, server unchanged" and skip re-downloading it
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
* of pulling it back down.
*/
fun removeLocalSync(context: Context, accountId: String, path: String) {
val root = syncRoot(context, accountId)
val state = loadState(context, accountId).toMutableMap()
var cleanPath = path.trim()
if (cleanPath.startsWith("/")) cleanPath = cleanPath.substring(1)
cleanPath = cleanPath.trimEnd('/')
val prefix = if (cleanPath.isEmpty()) "" else "$cleanPath/"
val toRemove = state.filterValues { s ->
s.relPath == cleanPath || (prefix.isNotEmpty() && s.relPath.startsWith(prefix))
}.keys
for (fileId in toRemove) state.remove(fileId)
saveState(context, accountId, state)
val target = if (cleanPath.isEmpty()) root else File(root, cleanPath)
if (target.exists()) target.deleteRecursively()
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
}
fun folderIsSyncedUnder(itemPath: String, syncedFolders: List<String>): String? { fun folderIsSyncedUnder(itemPath: String, syncedFolders: List<String>): String? {
val normalizedItem = itemPath.trimEnd('/') val normalizedItem = itemPath.trimEnd('/')
for (folder in syncedFolders) { for (folder in syncedFolders) {
+85 -17
View File
@@ -20,10 +20,15 @@ class SyncStatusController extends ChangeNotifier {
final SessionController session; final SessionController session;
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 _prefSyncOnCellular = 'ui_sync_on_cellular';
List<String> _syncedPaths = []; 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 _syncEverything = false;
bool _syncOnCellular = false; bool _syncOnCellular = false;
@@ -47,6 +52,16 @@ class SyncStatusController extends ChangeNotifier {
bool get isSyncingNow => _isSyncingNow; bool get isSyncingNow => _isSyncingNow;
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts); 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 { SyncHeaderStatus get syncHeaderStatus {
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert; if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
if (_isSyncingNow) return SyncHeaderStatus.syncing; if (_isSyncingNow) return SyncHeaderStatus.syncing;
@@ -54,7 +69,47 @@ class SyncStatusController extends ChangeNotifier {
return SyncHeaderStatus.done; 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) { 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 (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing;
if (_syncConflicts.any((c) => c.fileId == item.id)) { if (_syncConflicts.any((c) => c.fileId == item.id)) {
return SyncItemStatus.conflict; return SyncItemStatus.conflict;
@@ -71,6 +126,7 @@ class SyncStatusController extends ChangeNotifier {
void _onAccountCleared() { void _onAccountCleared() {
_syncedPaths = []; _syncedPaths = [];
_syncedPathTypes = {};
_syncEverything = false; _syncEverything = false;
_isSyncingNow = false; _isSyncingNow = false;
_syncingFileIds = {}; _syncingFileIds = {};
@@ -95,6 +151,19 @@ class SyncStatusController extends ChangeNotifier {
} else { } else {
_syncedPaths = []; _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; _syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false;
notifyListeners(); notifyListeners();
} }
@@ -117,30 +186,40 @@ class SyncStatusController extends ChangeNotifier {
void _persistSyncedPaths() { void _persistSyncedPaths() {
final id = session.activeAccountId; final id = session.activeAccountId;
if (id == null) return; if (id == null) return;
session.prefsFuture.then( session.prefsFuture.then((p) {
(p) => p.setString( p.setString(
session.accountStore.accountPrefKey(id, _prefSyncedPaths), session.accountStore.accountPrefKey(id, _prefSyncedPaths),
jsonEncode(_syncedPaths), jsonEncode(_syncedPaths),
), );
); p.setString(
session.accountStore.accountPrefKey(id, _prefSyncedPathTypes),
jsonEncode(_syncedPathTypes),
);
});
} }
bool isPathSynced(String path) => _syncedPaths.contains(path); bool isPathSynced(String path) => _syncedPaths.contains(path);
void addSyncedPath(String path) { void addSyncedPath(String path, {required bool isFolder}) {
if (_syncedPaths.contains(path)) return; if (_syncedPaths.contains(path)) return;
_syncedPaths = [..._syncedPaths, path]; _syncedPaths = [..._syncedPaths, path];
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
notifyListeners(); notifyListeners();
_persistSyncedPaths(); _persistSyncedPaths();
unawaited(SyncService.reschedule(session, this)); 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) { void removeSyncedPath(String path) {
if (!_syncedPaths.contains(path)) return; if (!_syncedPaths.contains(path)) return;
_syncedPaths = _syncedPaths.where((f) => f != path).toList(); _syncedPaths = _syncedPaths.where((f) => f != path).toList();
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
notifyListeners(); notifyListeners();
_persistSyncedPaths(); _persistSyncedPaths();
unawaited(SyncService.reschedule(session, this)); unawaited(SyncService.reschedule(session, this));
unawaited(SyncService.removeLocalSync(session, path));
} }
void setSyncEverything(bool value) { void setSyncEverything(bool value) {
@@ -192,18 +271,7 @@ class SyncStatusController extends ChangeNotifier {
Future<String?> localSyncedFilePath(NextcloudItem item) async { Future<String?> localSyncedFilePath(NextcloudItem item) async {
final id = session.activeAccountId; final id = session.activeAccountId;
if (id == null) return null; if (id == null) return null;
final itemPath = item.path.endsWith('/') if (!_isPathInSyncScope(item.path)) return null;
? 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;
return SyncService.localSyncedFilePath(id, item.path); return SyncService.localSyncedFilePath(id, item.path);
} }
+17
View File
@@ -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 /// The deterministic local mirror path for [remoteItemPath] under
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s /// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this /// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
+9 -1
View File
@@ -823,7 +823,15 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing', 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), const Divider(height: 1, indent: 16, endIndent: 16),
+43 -12
View File
@@ -292,18 +292,15 @@ class _FilesViewState extends State<FilesView>
label: 'Rename', label: 'Rename',
onTap: () => _renameItem(selected.single), onTap: () => _renameItem(selected.single),
), ),
if (selected.length == 1) SelectionAction(
SelectionAction( icon: selected.every((i) => sync.isPathSynced(i.path))
icon: sync.isPathSynced(selected.single.path) ? Icons.sync_rounded
? Icons.sync_rounded : Icons.sync_outlined,
: Icons.sync_outlined, label: selected.every((i) => sync.isPathSynced(i.path))
label: sync.isPathSynced(selected.single.path) ? 'Stop syncing to device'
? 'Stop syncing to device' : 'Sync to device',
: 'Sync to device', onTap: () => _toggleSyncSelected(context, sync, selected),
onTap: () => sync.isPathSynced(selected.single.path) ),
? sync.removeSyncedPath(selected.single.path)
: sync.addSyncedPath(selected.single.path),
),
if (selected.length == 1) if (selected.length == 1)
SelectionAction( SelectionAction(
icon: Icons.info_outline_rounded, icon: Icons.info_outline_rounded,
@@ -807,6 +804,40 @@ class _FilesViewState extends State<FilesView>
_clearSelection(); _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 /// Hands the whole batch off to `DownloadService.kt` (see its doc
/// comment) rather than downloading each item in Dart then prompting /// comment) rather than downloading each item in Dart then prompting
/// `file_saver` per file - same reasoning as `UploadService`/ /// `file_saver` per file - same reasoning as `UploadService`/
+22 -2
View File
@@ -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) { String formatBytes(int bytes) {
if (bytes <= 0) return '0 B'; if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes 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 // Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
// before the 100px lock threshold. // before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0); final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final status = syncStatus.syncHeaderStatus; final statusLabel = _syncSummary(syncStatus);
final (_, statusLabel) = _syncHeaderDisplay(status);
final conflicts = syncStatus.syncConflicts; final conflicts = syncStatus.syncConflicts;
return Stack( return Stack(