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
+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();