Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s

- Offline tab is now FilesView(offline: true) over a FolderBrowser interface
  (FilesController / OfflineController) sharing controls, tiles, thumbnails
- Synced files follow the Files Cache rule (default: refresh every 15 min):
  per-account WorkManager jobs, in-app timer, resume and pull triggers;
  root-etag shortcut skips full walks when nothing changed
- Single sync at a time (shared lock); logging out stops that account's sync
- Sync safety: PROPFIND failures skip the path instead of deleting local files
- Mirror empty folders and remove deleted ones; drop synced paths deleted on
  the server; missing local files are re-downloaded
- Notifications: silent per-account sync notifications, "Background sync
  notifications" setting, audible upload/download completion
- Files refresh in place (no spinner flash); retry after network returns
- Fix sync badges not updating (shared native status stream), "Sync off" on
  launch (eager providers), Files Cache section moved under Device Sync

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:06:55 -04:00
co-authored by Claude Sonnet 5
parent 49ae712f5e
commit f2bba2914e
38 changed files with 3133 additions and 741 deletions
+31 -20
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../models/sync_status.dart';
import '../providers/connectivity_controller.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
@@ -11,8 +13,12 @@ import '../providers/sync_status_controller.dart';
/// status (see `SyncStatusController.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) {
/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`). [isOffline] takes
/// priority over everything else - with no connection at all, "nothing
/// configured to sync" and "can't sync right now" are different facts,
/// and the latter is the one actually worth surfacing.
(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status, bool isOffline) {
if (isOffline) return (Icons.cloud_off_rounded, 'Offline');
return switch (status) {
SyncHeaderStatus.off => (Icons.cloud_off_rounded, 'Sync off'),
SyncHeaderStatus.syncing => (Icons.cloud_sync_rounded, 'Syncing…'),
@@ -21,25 +27,25 @@ 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) {
/// The expanded sync panel's headline - counts of what's actually
/// *configured* to sync (folders/files) instead of just repeating the
/// compact chip's generic "Synced" label. Deliberately doesn't add up the
/// individual files inside a synced folder ("1 folder synced", not "1
/// folder & 4 items synced") - once a folder's synced, its file count is
/// an implementation detail, not something the user picked.
String _syncSummary(SyncStatusController sync, bool isOffline) {
if (isOffline) return 'Offline';
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';
}
if (sync.syncEverything) return 'Whole account synced';
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';
final files = sync.syncedPaths.length - folders;
if (folders == 0 && files == 0) return 'Sync off';
final parts = <String>[
if (folders > 0) '$folders folder${folders == 1 ? '' : 's'}',
if (files > 0) '$files file${files == 1 ? '' : 's'}',
];
return '${parts.join(' & ')} synced';
}
String formatBytes(int bytes) {
@@ -143,6 +149,9 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
final refresh =
widget.onRefresh ?? context.read<FilesController>().refreshData;
setState(() => _isRefreshing = true);
// A deliberate pull also checks synced folders/files for server changes
// - the "sync" header is exactly where you'd expect that to happen.
unawaited(context.read<SyncStatusController>().syncOnPull());
refresh().whenComplete(() {
if (mounted) setState(() => _isRefreshing = false);
});
@@ -318,7 +327,8 @@ class _SyncStatusChip extends StatelessWidget {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final status = context.watch<SyncStatusController>().syncHeaderStatus;
final (icon, label) = _syncHeaderDisplay(status);
final isOffline = context.watch<ConnectivityController>().isOffline;
final (icon, label) = _syncHeaderDisplay(status, isOffline);
final iconColor = status == SyncHeaderStatus.alert
? colorScheme.error
: colorScheme.primary;
@@ -372,6 +382,7 @@ class _SyncedStretchPanel extends StatelessWidget {
final quota = context.watch<FilesController>().quota;
final serverUrl = context.watch<SessionController>().serverUrl;
final syncStatus = context.watch<SyncStatusController>();
final isOffline = context.watch<ConnectivityController>().isOffline;
return LayoutBuilder(
builder: (context, constraints) {
@@ -382,7 +393,7 @@ class _SyncedStretchPanel extends StatelessWidget {
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
// before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final statusLabel = _syncSummary(syncStatus);
final statusLabel = _syncSummary(syncStatus, isOffline);
final conflicts = syncStatus.syncConflicts;
return Stack(