Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s
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:
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user