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
+12 -1
View File
@@ -14,7 +14,12 @@ import '../views/trash_view.dart';
Widget buildAppTabView(AppTab tab, ScrollController controller) {
switch (tab) {
case AppTab.files:
return FilesView(scrollController: controller);
// Keyed so the Files and Offline tabs (same widget type) never share
// State when the visible-tab list shifts positions in the IndexedStack.
return FilesView(
key: const ValueKey('files'),
scrollController: controller,
);
case AppTab.photos:
return PhotosView(scrollController: controller);
case AppTab.favorites:
@@ -27,5 +32,11 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
return SharesView(scrollController: controller);
case AppTab.recent:
return RecentView(scrollController: controller);
case AppTab.offline:
return FilesView(
key: const ValueKey('offline'),
scrollController: controller,
offline: true,
);
}
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../providers/files_controller.dart';
import 'segmented_icon_toggle.dart';
import 'sort_menu_button.dart';
/// The Files tab's sort/hidden/scope/type-filter/view-mode controls row,
/// shared with the Offline tab so both read and write the exact same
/// [FilesController] prefs. [folderPath] picks which folder's sort applies
/// (the Files tab's current folder, or the folder being browsed offline).
/// [showStorageScope] is off for Offline - a local mirror has no
/// cloud/external distinction.
class FilesControlsRow extends StatelessWidget {
final String folderPath;
final bool showStorageScope;
final ScrollController? scrollController;
const FilesControlsRow({
super.key,
required this.folderPath,
this.showStorageScope = true,
this.scrollController,
});
@override
Widget build(BuildContext context) {
final files = context.watch<FilesController>();
return SizedBox(
height: 44,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.sortAscendingFor(folderPath)
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.sortAscendingFor(folderPath)
? 'Ascending'
: 'Descending',
onPressed: () => files.toggleSortOrderFor(folderPath),
),
// A plain width, not Expanded - this row scrolls horizontally,
// which gives every control unbounded width to lay out in, so a
// flex child would throw.
SizedBox(
width: 130,
child: SortMenuButton(
field: files.sortFieldFor(folderPath),
onChanged: (field) => files.setSortFieldFor(folderPath, field),
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
if (showStorageScope) ...[
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
],
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
);
}
}
+20 -6
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import '../services/nextcloud_service.dart';
@@ -56,10 +57,16 @@ class ItemThumbnail extends StatelessWidget {
final double borderRadius;
final double iconSize;
/// The on-device copy of [item] (the Offline tab's synced mirror) - when
/// set, images render from this file instead of a server preview, so
/// thumbnails work with no connection.
final File? localFile;
const ItemThumbnail({
super.key,
required this.item,
required this.service,
this.localFile,
required this.size,
required this.borderRadius,
required this.iconSize,
@@ -71,6 +78,7 @@ class ItemThumbnail extends StatelessWidget {
final isMedia =
item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video;
final useLocal = item.type == NextcloudItemType.image && localFile != null;
// Decode straight to the size this thumbnail is actually painted at —
// the server hands back a 500x500 preview regardless, and decoding that
// in full for a ~40dp tile (times however many are on screen while
@@ -86,16 +94,22 @@ class ItemThumbnail extends StatelessWidget {
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(borderRadius),
),
child: isMedia && item.previewUrl != null
child: useLocal || (isMedia && item.previewUrl != null)
? Stack(
fit: StackFit.expand,
children: [
Image.network(
item.previewUrl!,
headers: service?.authHeaders,
Image(
image: ResizeImage(
useLocal
? FileImage(localFile!) as ImageProvider
: NetworkImage(
item.previewUrl!,
headers: service?.authHeaders,
),
width: cachePixels,
height: cachePixels,
),
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
loadingBuilder: (context, child, progress) {
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/sync_status_controller.dart';
/// The configured sync targets (folders/files) with a "stop syncing"
/// action per target - what used to be Settings' Device Sync card's own
/// inline list, moved into a sheet here so the main Offline view can look
/// like a plain Files-style browser instead of always showing this
/// management UI up top.
class ManageSyncedFoldersSheet extends StatelessWidget {
final SyncStatusController sync;
const ManageSyncedFoldersSheet({super.key, required this.sync});
static void show(BuildContext context) {
final sync = context.read<SyncStatusController>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => ManageSyncedFoldersSheet(sync: sync),
);
}
void _remove(BuildContext context, String path) {
sync.removeSyncedPath(path);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Removed from device sync'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: sync,
builder: (context, _) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final everything = sync.syncEverything;
final targets = sync.syncedPaths;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Synced to this device',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (everything)
Text(
'Every folder in this account is being synced to this '
'device.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else if (targets.isEmpty)
Text(
'Nothing synced yet - select a folder or file in Files '
'and use "Sync to device" to mirror it here for offline '
'access.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: targets.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final target = targets[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.sync_rounded),
title: Text(target),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing',
onPressed: () => _remove(context, target),
),
);
},
),
),
],
),
),
);
},
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import 'synced_header_scaffold.dart' show formatBytes;
/// Grid content for an image/video: the actual preview fills the whole card
/// as a background, with the name/size legible over a bottom scrim -
/// matching a Google Photos-style grid instead of a small icon badge.
/// Shared by the Files and Offline tabs; they differ only in where the
/// pixels come from ([imageBuilder] - a server preview vs. the local
/// mirror file) and what to show if that fails ([fallbackBuilder]).
class MediaGridTile extends StatelessWidget {
final NextcloudItem item;
/// Given the pixel width to decode at, returns the image to paint.
/// Decoding straight to the painted size (rather than the full 500x500
/// server preview / full-resolution local file) avoids a real source of
/// scrolling jank.
final ImageProvider Function(int cachePixels) imageBuilder;
final WidgetBuilder fallbackBuilder;
const MediaGridTile({
super.key,
required this.item,
required this.imageBuilder,
required this.fallbackBuilder,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final cachePixels =
(constraints.maxWidth * MediaQuery.of(context).devicePixelRatio)
.round();
return Stack(
fit: StackFit.expand,
children: [
Image(
image: imageBuilder(cachePixels),
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => fallbackBuilder(ctx),
),
if (item.type == NextcloudItemType.video)
const Center(
child: Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 36,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 20, 10, 8),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
Text(
formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 10,
),
),
],
),
),
),
],
);
},
);
}
}
+6 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../providers/connectivity_controller.dart';
import '../providers/pick_controller.dart';
import '../providers/settings_controller.dart';
import 'app_tab_view_builder.dart';
@@ -16,13 +17,16 @@ class MoreTabsButton extends StatelessWidget {
Widget build(BuildContext context) {
final pick = context.watch<PickController>();
final settings = context.watch<SettingsController>();
final connectivity = context.watch<ConnectivityController>();
// While picking (another app's GET_CONTENT request), MainShellView
// already restricts the bottom nav to Files/Photos - this button would
// otherwise be the one remaining way to reach a tab that isn't a valid
// pick source (Trash, Shares, ...), so it hides entirely rather than
// just disabling.
if (pick.isPicking) return const SizedBox.shrink();
// just disabling. Same idea while offline: every hidden tab needs a
// network request to show anything useful, so there's nothing this
// button could usefully open.
if (pick.isPicking || connectivity.isOffline) return const SizedBox.shrink();
return PopupMenuButton<AppTab>(
icon: const Icon(Icons.apps_rounded),
+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(