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:
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user