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 -4
View File
@@ -6,9 +6,11 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/app_tab.dart';
import 'models/pick_request.dart';
import 'providers/connectivity_controller.dart';
import 'providers/favorites_controller.dart';
import 'providers/files_controller.dart';
import 'providers/item_operations.dart';
import 'providers/offline_controller.dart';
import 'providers/photos_controller.dart';
import 'providers/pick_controller.dart';
import 'providers/recent_controller.dart';
@@ -38,14 +40,26 @@ void main() {
// replaced/recreated for the app's lifetime (account switching is
// internal state on SessionController, not a new provider instance).
providers: [
ChangeNotifierProvider(create: (_) => SessionController()),
ChangeNotifierProvider(create: (_) => SettingsController()),
ChangeNotifierProvider(create: (_) => ConnectivityController()),
ChangeNotifierProvider(
create: (context) => SyncStatusController(context.read()),
create: (context) => SessionController(context.read()),
),
ChangeNotifierProvider(create: (_) => SettingsController()),
// Before SyncStatusController, which follows its Files Cache rule
// for how often synced files refresh.
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
// Eager (`lazy: false`) - this controller only loads its persisted
// state when SessionController fires the account-ready event, and
// that fires once at startup, before any widget has read this
// provider. Lazily created, it would register its listener too
// late and stay empty ("Sync off") until an account switch.
lazy: false,
create: (context) =>
SyncStatusController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) =>
PhotosController(context.read(), context.read()),
@@ -66,6 +80,12 @@ void main() {
ChangeNotifierProvider(
create: (context) => PickController(context.read()),
),
ChangeNotifierProvider(
// Eager for the same reason as SyncStatusController above.
lazy: false,
create: (context) =>
OfflineController(context.read(), context.read()),
),
Provider(
create: (context) => ItemOperations(
session: context.read(),
@@ -276,6 +296,7 @@ class _MainShellViewState extends State<MainShellView> {
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>();
final connectivity = context.watch<ConnectivityController>();
// A one-shot request (e.g. tapping a search result) to switch tabs -
// consumed here so it only fires once, then cleared after this frame
@@ -294,9 +315,15 @@ class _MainShellViewState extends State<MainShellView> {
// real "pick a file from here" destinations, and only these two tabs'
// views even know how to handle picking-mode taps. This overrides the
// user's own hidden/reordered tab settings rather than respecting
// them, since picking is a separate mode from normal browsing.
// them, since picking is a separate mode from normal browsing. With
// no network at all, every other tab would just show its own loading
// spinner forever/an error state, so the bottom nav collapses to just
// Offline - the one tab that works without a connection (see
// `FilesView(offline: true)`/`ConnectivityController`).
final visible = pickRequest != null
? [AppTab.files, AppTab.photos]
: connectivity.isOffline
? [AppTab.offline]
: settings.visibleTabs;
final selectedTab = visible.contains(_currentTab)
? _currentTab