Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user