Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s

- New native sync engine (SyncEngine/SyncWorker/ConflictResolveWorker,
  WorkManager-based) that mirrors selected Nextcloud folders or files to
  app-private local storage, periodically and on-demand ("Sync now"),
  with never-auto-resolved conflict notifications.
- Single files (not just folders) can now be marked "Sync to device".
- Live sync status (syncing/synced/conflicts) pushes from native to Dart
  over a new EventChannel; the persistent header chip/panel now reflects
  device-sync status instead of the WebDAV-refresh loading state, with a
  cloud_off/cloud_sync/cloud_done/cloud_alert icon set and an expandable
  conflicts list with in-app "Keep local"/"Use server" resolution.
  Per-item cloud_done/sync badges show on Files tiles.
- Share/Download short-circuit to the local copy for already-synced
  files instead of a fresh network fetch.
- Settings gained a Device Sync section (per-folder list, "Sync
  everything", Wi-Fi-only toggle, manual "Sync now").
- Fixed two release-only bugs found via on-device testing: Android's
  HttpURLConnection silently rejects the PROPFIND method (switched to
  OkHttp), and R8 was stripping WorkManager's reflection-instantiated
  internals (broadened proguard-rules.pro to keep androidx.work.**
  wholesale rather than chasing individual classes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 00:52:56 -04:00
co-authored by Claude Sonnet 5
parent 722b66f7b1
commit faaec5365f
21 changed files with 2023 additions and 42 deletions
+38
View File
@@ -0,0 +1,38 @@
/// A pending device-sync conflict (a file changed both locally and on the
/// server since the last sync) - mirrors `SyncStatusBus.Conflict` on the
/// native side exactly, see `SyncEngine.kt`/`server.md`'s "Device sync"
/// section.
class SyncConflictInfo {
final String accountId;
final String fileId;
final String remotePath;
final String relPath;
final String name;
const SyncConflictInfo({
required this.accountId,
required this.fileId,
required this.remotePath,
required this.relPath,
required this.name,
});
factory SyncConflictInfo.fromMap(Map<dynamic, dynamic> map) {
return SyncConflictInfo(
accountId: map['accountId'] as String? ?? '',
fileId: map['fileId'] as String? ?? '',
remotePath: map['remotePath'] as String? ?? '',
relPath: map['relPath'] as String? ?? '',
name: map['name'] as String? ?? '',
);
}
}
/// A single item's device-sync status, used to badge its tile in the
/// Files view.
enum SyncItemStatus { none, syncing, synced, conflict }
/// The persistent header chip/panel's overall status (account-wide, not
/// scoped to whatever folder is currently browsed) - see
/// `ServerProvider.syncHeaderStatus`.
enum SyncHeaderStatus { off, syncing, done, alert }
+180
View File
@@ -14,11 +14,13 @@ import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../models/pick_request.dart';
import '../models/saved_account.dart';
import '../models/sync_status.dart';
import '../services/account_store.dart';
import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart';
import '../services/pick_intent_service.dart';
import '../services/sync_service.dart';
import '../theme/app_theme.dart';
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
@@ -97,6 +99,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _prefLoginLockEnabled = 'ui_login_lock_enabled';
static const _prefLockAccountSwitching = 'ui_lock_account_switching';
static const _prefLockHiddenFiles = 'ui_lock_hidden_files';
static const _prefSyncedFolders = 'ui_synced_folders';
static const _prefSyncEverything = 'ui_sync_everything';
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance();
@@ -127,6 +132,23 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true;
// Device sync - which remote paths (files or folders, per account) get
// mirrored locally by the native SyncWorker, and whether its periodic
// background runs are allowed on cellular (default Wi-Fi-only). See
// SyncEngine.kt/server.md.
List<String> _syncedPaths = [];
bool _syncEverything = false;
bool _syncOnCellular = false;
// Live device-sync status, pushed from SyncStatusBus.kt via
// SyncService.statusStream (see _subscribeToSyncStatus) - account-wide,
// not scoped to whatever folder is currently browsed.
bool _isSyncingNow = false;
Set<String> _syncingFileIds = {};
Set<String> _syncedFileIds = {};
List<SyncConflictInfo> _syncConflicts = [];
StreamSubscription<SyncStatusSnapshot>? _syncStatusSub;
// App lock (PIN/biometric via the device's own credential, not our own
// storage - see AppLockService). Global, not per-account: it guards
// access to the app/its accounts, not any one account's content.
@@ -255,6 +277,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
ServerProvider() {
WidgetsBinding.instance.addObserver(this);
_init();
_subscribeToSyncStatus();
}
Future<void> _init() async {
@@ -265,6 +288,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
await Future.wait([_loadPreferences(), _restoreSession()]);
}
/// Live device-sync status for the rest of the app's lifetime (not
/// re-subscribed per account switch - each event carries its own
/// `accountId`, so events for an account that isn't currently active are
/// just ignored below). Seeded once via a one-shot snapshot right after
/// every successful login/account-switch instead (see
/// `_applyCredentialsForAccount`), since events only arrive on change.
void _subscribeToSyncStatus() {
_syncStatusSub = SyncService.statusStream.listen((snapshot) {
_applySyncStatus(snapshot);
});
}
void _applySyncStatus(SyncStatusSnapshot snapshot) {
if (snapshot.accountId != null && snapshot.accountId != _activeAccountId) {
return;
}
_isSyncingNow = snapshot.syncing;
_syncingFileIds = snapshot.syncingFileIds;
_syncedFileIds = snapshot.syncedFileIds;
_syncConflicts = snapshot.conflicts;
notifyListeners();
}
/// Starts/stops the periodic folder-listing refresh as the app leaves and
/// returns to the foreground - only relevant under [CachePolicy.interval].
/// Also re-locks the app on backgrounding when login lock is set up - a
@@ -323,6 +369,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
bool get lockHiddenFiles => _lockHiddenFiles;
bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
List<String> get syncedPaths => List.unmodifiable(_syncedPaths);
bool get syncEverything => _syncEverything;
bool get syncOnCellular => _syncOnCellular;
bool get isSyncingNow => _isSyncingNow;
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
SyncHeaderStatus get syncHeaderStatus {
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
if (_isSyncingNow) return SyncHeaderStatus.syncing;
if (!_syncEverything && _syncedPaths.isEmpty) return SyncHeaderStatus.off;
return SyncHeaderStatus.done;
}
SyncItemStatus syncStatusFor(NextcloudItem item) {
if (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing;
if (_syncConflicts.any((c) => c.fileId == item.id)) {
return SyncItemStatus.conflict;
}
if (_syncedFileIds.contains(item.id)) return SyncItemStatus.synced;
return SyncItemStatus.none;
}
String get currentFolderPath => _currentFolderPath;
AppTab? get requestedTab => _requestedTab;
@@ -621,6 +690,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
_lockHiddenFiles =
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
_applyAccountPrefs(prefs, _activeAccountId);
final savedOrderNames = prefs.getStringList(_prefTabOrder);
@@ -709,6 +779,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_photosSortAscending = true;
_cachePolicy = CachePolicy.never;
_cacheIntervalMinutes = 5;
_syncedPaths = [];
_syncEverything = false;
return;
}
@@ -767,6 +839,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
orElse: () => CachePolicy.never,
);
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
final syncedFoldersJson = prefs.getString(k(_prefSyncedFolders));
if (syncedFoldersJson != null) {
try {
_syncedPaths = (jsonDecode(syncedFoldersJson) as List).cast<String>();
} catch (e) {
debugPrint('[ServerProvider] Synced folders restore failed: $e');
_syncedPaths = [];
}
} else {
_syncedPaths = [];
}
_syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false;
}
/// Verifies [appPassword] for [account] and, on success, makes it the
@@ -806,6 +891,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (gen != _sessionGeneration) return false;
_isLoading = false;
notifyListeners();
unawaited(SyncService.reschedule(this));
unawaited(SyncService.getStatus().then(_applySyncStatus));
return true;
}
} catch (e) {
@@ -1131,7 +1218,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
final prefs = await _prefsFuture;
await _accountStore.saveActiveAccountId(prefs, null);
_applyAccountPrefs(prefs, null);
_isSyncingNow = false;
_syncingFileIds = {};
_syncedFileIds = {};
_syncConflicts = [];
notifyListeners();
unawaited(SyncService.cancel());
}
/// Ends the active session but keeps this account saved - unlike
@@ -1473,6 +1565,93 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_persistFolderSort();
}
void _persistSyncedPaths() {
_persistAccountPref(
_prefSyncedFolders,
(p, key) => p.setString(key, jsonEncode(_syncedPaths)),
);
}
bool isPathSynced(String path) => _syncedPaths.contains(path);
void addSyncedPath(String path) {
if (_syncedPaths.contains(path)) return;
_syncedPaths = [..._syncedPaths, path];
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(this));
}
void removeSyncedPath(String path) {
if (!_syncedPaths.contains(path)) return;
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(this));
}
void setSyncEverything(bool value) {
if (_syncEverything == value) return;
_syncEverything = value;
notifyListeners();
_persistAccountPref(_prefSyncEverything, (p, key) => p.setBool(key, value));
unawaited(SyncService.reschedule(this));
}
void setSyncOnCellular(bool value) {
if (_syncOnCellular == value) return;
_syncOnCellular = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value));
unawaited(SyncService.reschedule(this));
}
/// 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
/// actions use.
Future<void> resolveSyncConflict(
SyncConflictInfo conflict, {
required bool useLocal,
}) {
return SyncService.resolveConflict(
this,
conflict,
useLocal ? 'local' : 'server',
);
}
/// The local mirror path for [item] if it falls under a synced folder
/// *and* has actually been synced down already - a pure function of the
/// remote path once a folder's marked synced (mirrors
/// `SyncEngine.kt#syncRoot`'s `<externalFilesDir>/sync/<accountId>/...`
/// layout exactly), so this never needs to read the native sync-state
/// SharedPreferences from Dart. Callers (Share/Download) use this to
/// skip a fresh WebDAV fetch when a local copy already exists.
Future<String?> localSyncedFilePath(NextcloudItem item) async {
final id = 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;
final base = await getExternalStorageDirectory();
if (base == null) return null;
final relPath = item.path.startsWith('/')
? item.path.substring(1)
: item.path;
final file = File(p.join(base.path, 'sync', id, relPath));
return file.existsSync() ? file.path : null;
}
void setTabOrder(List<AppTab> order) {
_tabOrder = order;
notifyListeners();
@@ -2154,6 +2333,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_pollTimer?.cancel();
_pollTimeoutTimer?.cancel();
_cacheRefreshTimer?.cancel();
_syncStatusSub?.cancel();
super.dispose();
}
}
+2
View File
@@ -37,6 +37,8 @@ class AccountStore {
'ui_files_type_filter',
'ui_cache_policy',
'ui_cache_interval_minutes',
'ui_synced_folders',
'ui_sync_everything',
];
String accountPrefKey(String accountId, String baseKey) =>
+144
View File
@@ -0,0 +1,144 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/sync_status.dart';
import '../providers/server_provider.dart';
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
class SyncStatusSnapshot {
final String? accountId;
final bool syncing;
final Set<String> syncingFileIds;
final Set<String> syncedFileIds;
final List<SyncConflictInfo> conflicts;
const SyncStatusSnapshot({
required this.accountId,
required this.syncing,
required this.syncingFileIds,
required this.syncedFileIds,
required this.conflicts,
});
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
return SyncStatusSnapshot(
accountId: map['accountId'] as String?,
syncing: map['syncing'] as bool? ?? false,
syncingFileIds: ((map['syncingFileIds'] as List?) ?? const [])
.cast<String>()
.toSet(),
syncedFileIds: ((map['syncedFileIds'] as List?) ?? const [])
.cast<String>()
.toSet(),
conflicts: ((map['conflicts'] as List?) ?? const [])
.cast<Map<dynamic, dynamic>>()
.map(SyncConflictInfo.fromMap)
.toList(),
);
}
}
/// Talks to `SyncWorker`/`SyncEngine.kt`'s Android WorkManager-based device
/// sync (see `server.md`'s "Device sync" section for the full design) -
/// Dart's job here is only to gather what the native side needs, push a
/// fresh config whenever it changes, and listen for live status; the actual
/// PROPFIND-walk/diff/GET/PUT engine runs entirely in Kotlin, independent
/// of the Flutter engine.
class SyncService {
static const _channel = MethodChannel('dev.ayushya.noo/sync_service');
static const _statusChannel = EventChannel(
'dev.ayushya.noo/sync_service/status',
);
/// Cancels (if [ServerProvider.syncedPaths] is now empty) or re-enqueues
/// the periodic sync job with fresh account/credentials/path-list/
/// network-constraint data - call this any time one of those changes,
/// since a periodic `WorkRequest`'s input is fixed at enqueue time and
/// can only be updated by re-enqueueing.
static Future<void> reschedule(ServerProvider provider) async {
final accountId = provider.activeAccountId;
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (accountId == null || service == null || authHeader == null) {
return cancel();
}
final paths = provider.syncEverything ? ['/'] : provider.syncedPaths;
await _channel.invokeMethod('reschedule', {
'accountId': accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
'wifiOnly': !provider.syncOnCellular,
});
}
static Future<void> cancel() async {
await _channel.invokeMethod('cancel');
}
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
/// independent of the periodic schedule.
static Future<void> syncNow(ServerProvider provider) async {
final accountId = provider.activeAccountId;
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (accountId == null || service == null || authHeader == null) {
throw Exception('Not logged in.');
}
final paths = provider.syncEverything ? ['/'] : provider.syncedPaths;
await _channel.invokeMethod('syncNow', {
'accountId': accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
});
}
/// One-shot status snapshot - used to seed [ServerProvider]'s state right
/// after login/account-switch, before the first [statusStream] event
/// arrives.
static Future<SyncStatusSnapshot> getStatus() async {
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
'getSyncStatus',
);
return SyncStatusSnapshot.fromMap(result ?? const {});
}
/// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing
/// started/stopped, per-file progress, new/resolved conflicts.
static Stream<SyncStatusSnapshot> get statusStream {
return _statusChannel.receiveBroadcastStream().map(
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
}
/// In-app conflict resolution (the header's "Keep local"/"Use server"
/// buttons) - shares the exact same native `ConflictResolveWorker`
/// enqueue path the notification actions use, just triggered from Dart
/// instead of a `PendingIntent`.
static Future<void> resolveConflict(
ServerProvider provider,
SyncConflictInfo conflict,
String resolution,
) async {
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (service == null || authHeader == null) {
throw Exception('Not logged in.');
}
await _channel.invokeMethod('resolveConflict', {
'accountId': conflict.accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
'fileId': conflict.fileId,
'remotePath': conflict.remotePath,
'relPath': conflict.relPath,
'resolution': resolution,
});
}
}
+126
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/saved_account.dart';
import '../providers/server_provider.dart';
import '../services/sync_service.dart';
import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart';
@@ -237,6 +238,17 @@ class AccountView extends StatelessWidget {
const _SecurityCard(),
const SizedBox(height: 24),
// Device Sync Section
Text(
'Device Sync',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
const _DeviceSyncCard(),
const SizedBox(height: 24),
// Material You Design Settings
Text(
'Material You Aesthetics',
@@ -722,6 +734,120 @@ class _SecurityCard extends StatelessWidget {
}
}
class _DeviceSyncCard extends StatefulWidget {
const _DeviceSyncCard();
@override
State<_DeviceSyncCard> createState() => _DeviceSyncCardState();
}
class _DeviceSyncCardState extends State<_DeviceSyncCard> {
bool _syncingNow = false;
Future<void> _syncNow(ServerProvider provider) async {
setState(() => _syncingNow = true);
try {
await SyncService.syncNow(provider);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not start sync: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _syncingNow = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final folders = provider.syncedPaths;
final everything = provider.syncEverything;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
secondary: const Icon(Icons.cloud_sync_rounded),
title: const Text('Sync everything'),
subtitle: const Text(
'Mirror the whole account instead of picking folders',
),
value: everything,
onChanged: provider.setSyncEverything,
),
const Divider(height: 1, indent: 16, endIndent: 16),
if (everything)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Every folder in this account is being synced to this '
'device.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
else if (folders.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'No folders synced yet - select a folder in Files and use '
'"Sync to device" to mirror it here for offline access.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
else
for (final folder in folders) ...[
ListTile(
leading: const Icon(Icons.sync_rounded),
title: Text(folder),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing',
onPressed: () => provider.removeSyncedPath(folder),
),
),
const Divider(height: 1, indent: 16, endIndent: 16),
],
SwitchListTile(
secondary: const Icon(Icons.signal_cellular_alt_rounded),
title: const Text('Sync on cellular'),
subtitle: const Text('Off = background sync only runs on Wi-Fi'),
value: provider.syncOnCellular,
onChanged: provider.setSyncOnCellular,
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: _syncingNow
? const Padding(
padding: EdgeInsets.all(2),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.sync_rounded),
title: const Text('Sync now'),
enabled: (everything || folders.isNotEmpty) && !_syncingNow,
onTap: () => _syncNow(provider),
),
],
),
);
}
}
class _BottomBarAppearanceCard extends StatefulWidget {
const _BottomBarAppearanceCard();
+93 -17
View File
@@ -1,8 +1,10 @@
import 'package:file_picker/file_picker.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
@@ -21,6 +23,7 @@ import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/swipeable_item.dart';
import '../widgets/sync_status_badge.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
@@ -284,6 +287,18 @@ class _FilesViewState extends State<FilesView>
label: 'Rename',
onTap: () => _renameItem(provider, selected.single),
),
if (selected.length == 1)
SelectionAction(
icon: provider.isPathSynced(selected.single.path)
? Icons.sync_rounded
: Icons.sync_outlined,
label: provider.isPathSynced(selected.single.path)
? 'Stop syncing to device'
: 'Sync to device',
onTap: () => provider.isPathSynced(selected.single.path)
? provider.removeSyncedPath(selected.single.path)
: provider.addSyncedPath(selected.single.path),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
@@ -803,6 +818,46 @@ class _FilesViewState extends State<FilesView>
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
// Already mirrored locally by device sync (every selected file, not
// just some)? Save straight from the local copy instead of a fresh
// network fetch through DownloadService - see
// ServerProvider.localSyncedFilePath.
final localPaths = await Future.wait(
files.map(provider.localSyncedFilePath),
);
if (localPaths.every((path) => path != null)) {
try {
for (var i = 0; i < files.length; i++) {
final ext = p.extension(files[i].name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(files[i].name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: localPaths[i]!,
ext: ext,
);
}
messenger.showSnackBar(
SnackBar(
content: Text(
files.length == 1
? 'Downloaded ${files.first.name}'
: 'Downloaded ${files.length} files',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not save file(s): $e'),
behavior: SnackBarBehavior.floating,
),
);
}
return;
}
try {
await DownloadService.startDownload(provider, files);
messenger.showSnackBar(
@@ -930,17 +985,29 @@ class _FilesViewState extends State<FilesView>
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
size: 44,
borderRadius: 12,
iconSize: 22,
),
Stack(
clipBehavior: Clip.none,
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
Positioned(
right: -2,
bottom: -2,
child: SyncStatusBadge(
status: provider.syncStatusFor(item),
),
),
],
),
const SizedBox(width: 14),
Expanded(
@@ -1036,12 +1103,21 @@ class _FilesViewState extends State<FilesView>
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? _buildMediaGridContent(context, item, provider)
: _buildPlainGridContent(context, item),
child: Stack(
children: [
SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? _buildMediaGridContent(context, item, provider)
: _buildPlainGridContent(context, item),
),
Positioned(
right: 6,
bottom: 6,
child: SyncStatusBadge(status: provider.syncStatusFor(item)),
),
],
),
),
);
+11 -3
View File
@@ -236,9 +236,17 @@ class _ShareSheetState extends State<ShareSheet> {
setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context);
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, widget.item.name);
await provider.service!.downloadToFile(widget.item.path, tempPath);
// Already mirrored locally by device sync? Share that copy straight
// away instead of a fresh WebDAV fetch - see
// ServerProvider.localSyncedFilePath.
final tempPath =
await provider.localSyncedFilePath(widget.item) ??
await () async {
final tempDir = await getTemporaryDirectory();
final path = p.join(tempDir.path, widget.item.name);
await provider.service!.downloadToFile(widget.item.path, path);
return path;
}();
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../models/sync_status.dart';
/// A small corner badge over a thumbnail showing device-sync status - a
/// `cloud_done` badge once a file's mirrored locally, `sync` while it's
/// actively being transferred. Nothing is drawn for `none`/`conflict`
/// (conflicts surface in the sync header, not as per-tile noise).
class SyncStatusBadge extends StatelessWidget {
final SyncItemStatus status;
const SyncStatusBadge({super.key, required this.status});
@override
Widget build(BuildContext context) {
if (status != SyncItemStatus.syncing && status != SyncItemStatus.synced) {
return const SizedBox.shrink();
}
final colorScheme = Theme.of(context).colorScheme;
final icon = status == SyncItemStatus.syncing
? Icons.sync_rounded
: Icons.cloud_done_rounded;
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 1.5),
),
child: Icon(icon, size: 13, color: colorScheme.primary),
);
}
}
+131 -11
View File
@@ -1,7 +1,23 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/nextcloud_item.dart';
import '../models/sync_status.dart';
import '../providers/server_provider.dart';
/// Icon/label for the persistent header chip/panel - reflects device-sync
/// status (see `ServerProvider.syncHeaderStatus`), not the WebDAV
/// directory-listing refresh the pull gesture itself triggers (that has
/// its own, separate floating spinner bubble - see
/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`).
(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status) {
return switch (status) {
SyncHeaderStatus.off => (Icons.cloud_off_rounded, 'Sync off'),
SyncHeaderStatus.syncing => (Icons.cloud_sync_rounded, 'Syncing…'),
SyncHeaderStatus.done => (Icons.cloud_done_rounded, 'Synced'),
SyncHeaderStatus.alert => (Symbols.cloud_alert_rounded, 'Sync issue'),
};
}
String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
@@ -188,7 +204,9 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
stretch: widget.selectionBar == null,
expandedHeight: widget.selectionBar != null
? kToolbarHeight
: (_headerLocked ? 190 : kToolbarHeight),
: (_headerLocked
? 190.0 + provider.syncConflicts.length * 52.0
: kToolbarHeight),
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer,
@@ -278,7 +296,11 @@ class _SyncStatusChip extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final quota = provider.quota;
final status = provider.syncHeaderStatus;
final (icon, label) = _syncHeaderDisplay(status);
final iconColor = status == SyncHeaderStatus.alert
? colorScheme.error
: colorScheme.primary;
return Padding(
padding: const EdgeInsets.only(left: 12),
@@ -293,16 +315,10 @@ class _SyncStatusChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
provider.isLoading
? Icons.cloud_sync_rounded
: Icons.cloud_done_outlined,
color: colorScheme.primary,
size: 18,
),
Icon(icon, color: iconColor, size: 18),
const SizedBox(width: 6),
Text(
quota != null ? formatQuota(quota) : 'Sync',
label,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
@@ -348,6 +364,9 @@ class _SyncedStretchPanel extends StatelessWidget {
// before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final quota = provider.quota;
final status = provider.syncHeaderStatus;
final (_, statusLabel) = _syncHeaderDisplay(status);
final conflicts = provider.syncConflicts;
return Stack(
children: [
@@ -365,7 +384,7 @@ class _SyncedStretchPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
provider.isLoading ? 'Syncing…' : 'Synced',
statusLabel,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
@@ -414,6 +433,17 @@ class _SyncedStretchPanel extends StatelessWidget {
],
),
),
if (conflicts.isNotEmpty) ...[
const SizedBox(height: 10),
for (final conflict in conflicts)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _SyncConflictRow(
conflict: conflict,
provider: provider,
),
),
],
],
),
),
@@ -424,3 +454,93 @@ class _SyncedStretchPanel extends StatelessWidget {
);
}
}
/// One row in the locked-open panel's conflicts section - a file that
/// changed both locally and on the server since the last sync, with the
/// same two resolutions the sync-conflict notification offers (see
/// `SyncWorker.notifyConflicts`/`ConflictResolveWorker` on the native
/// side), just triggered in-app instead.
class _SyncConflictRow extends StatefulWidget {
final SyncConflictInfo conflict;
final ServerProvider provider;
const _SyncConflictRow({required this.conflict, required this.provider});
@override
State<_SyncConflictRow> createState() => _SyncConflictRowState();
}
class _SyncConflictRowState extends State<_SyncConflictRow> {
bool _resolving = false;
Future<void> _resolve(bool useLocal) async {
setState(() => _resolving = true);
try {
await widget.provider.resolveSyncConflict(
widget.conflict,
useLocal: useLocal,
);
} catch (_) {
if (mounted) setState(() => _resolving = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Symbols.cloud_alert_rounded, color: colorScheme.error, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.conflict.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (_resolving)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else ...[
TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _resolve(true),
child: const Text('Keep local'),
),
TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _resolve(false),
child: const Text('Use server'),
),
],
],
),
);
}
}