diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index d956d0f..3f20809 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -8,7 +8,9 @@ lib/ # MainShellView (bottom-nav shell), share-intent listener models/ # plain data classes (NextcloudItem, NextcloudShare, # SavedAccount, AppTab, ...) - providers/ # ServerProvider — the single app-wide ChangeNotifier + providers/ # per-domain ChangeNotifiers (SessionController, + # FilesController, PhotosController, ...) plus + # ItemOperations, a plain cross-domain coordinator services/ # network/IO: NextcloudService, LoginFlowService, # AccountStore, AppLockService theme/ # AppTheme (Material 3 ThemeData) @@ -23,91 +25,119 @@ lib/ `views/` files are screens routed to directly (a tab, or pushed via `Navigator`). `widgets/` files are building blocks used by more than one view (or complex enough to warrant their own file) — nothing in `widgets/` -owns app state itself; it reads it from the `ServerProvider` passed down or -read via `context.watch`/`context.read`. +owns app state itself; it reads whichever controller(s) it needs via +`context.watch`/`context.read`. ## State management -There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart) -(~1500 lines). It is created once in `main()` and provided at the root with -`provider`'s `ChangeNotifierProvider`. It owns: +State used to live in one 2300+ line `ServerProvider` god object. It's now +split into ten focused `ChangeNotifier`s plus one plain coordinator, all +registered in `main()`'s `MultiProvider` in dependency order (later +providers read earlier ones via `context.read` in their `create` callback — +safe since none of these providers are ever recreated for the app's +lifetime; account switching is internal state on `SessionController`, not a +new provider instance): -- **Multi-account state**: the list of saved accounts (`accounts`), which one - is active (`activeAccountId`/`activeAccount`), and a `_sessionGeneration` - counter incremented on every account switch so an in-flight fetch from the - account just left can recognize it's stale and discard its result instead - of writing into the newly-active account's state — every method that - writes fetched data into a shared field (`refreshData`, `fetchAllMedia`, - `fetchTrash`, `fetchShares`, `fetchRecent`, - `_applyCredentialsForAccount`) captures the generation at entry and checks - it before each write. See `server.md` for the full account-switch/storage - story. -- auth/session state for whichever account is active (`isLoggedIn`, - `isRestoringSession`, login-flow status) -- the active `NextcloudService` instance (null until logged in) -- all fetched data, scoped to the active account (`items`, `quota`, - `activities`, `photoItems`, `trashItems`, `shares`, `recentItems`) — all - torn down and refetched fresh on every account switch (no simultaneous - multi-account state; only one account's content is ever live in memory) -- navigation-within-files state (`currentFolderPath`, `pathStack`), plus an - in-memory `_directoryCache` keyed by folder path (see `CachePolicy`) -- UI settings that persist across launches — split into **global** (theme - mode, seed color, dynamic-color toggle, bottom-bar opacity/blur, - tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe - actions, login lock, sync-on-cellular — see below) and **per-account** - (grid/list view, favorites-only, show-hidden, storage scope, Photos sort, - Files' per-folder sort map, cache policy, synced folders) — see - `server.md` for exactly which is which and why -- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/ - `lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via - `AppLockService` (a thin wrapper over `local_auth` — this app never - stores or hashes a PIN itself, it delegates entirely to whatever - credential the OS already has configured). `needsUnlock` is - `loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient - (never persisted) and reset to `false` on every backgrounding - (`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has - real value rather than only firing once per cold start. `switchAccount`/ - `cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not - disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared - `_passGate` helper, which no-ops unless both `loginLockEnabled` and the - relevant per-feature toggle are on. +- [`SessionController`](../../lib/providers/session_controller.dart) — the + foundation everything else depends on. Owns multi-account state (the + saved-accounts list, which one is active, `sessionGeneration` — see + below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`, + `loginFlowStatus`), the active `NextcloudService` instance, and login lock + (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/ + `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/ + `addAccountActivatedListener` (plain `List`) so sibling + controllers — constructed after `SessionController` and unable to hold a + forward reference to it — can react to login/logout/account-switch + without a circular dependency. +- [`SettingsController`](../../lib/providers/settings_controller.dart) — + global UI prefs independent of login state: theme mode/seed color/dynamic + color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab + order/visibility/default (`requestedTab`/`requestTab`/ + `consumeRequestedTab`), swipe actions. +- [`FilesController`](../../lib/providers/files_controller.dart) — the + Files tab: `items`/`currentFolderPath`/`pathStack`, the in-memory + `_directoryCache` (see `CachePolicy`), per-account display prefs + (grid/list, hidden files, storage scope, sort field/order, per-folder + sort map), and the shared `applyCommonFilters`/`applyFilesDisplayPrefs` + helpers Photos/Favorites reuse rather than duplicating the same + filter/sort logic. +- [`PhotosController`](../../lib/providers/photos_controller.dart) / + [`FavoritesController`](../../lib/providers/favorites_controller.dart) — + each depends on `FilesController` for the shared storage-scope toggle and + display-prefs helpers, but owns its own account-wide item list and + independent favorites-only/hidden/sort state. +- [`TrashController`](../../lib/providers/trash_controller.dart) / + [`SharesController`](../../lib/providers/shares_controller.dart) / + [`RecentController`](../../lib/providers/recent_controller.dart) — one + per remaining tab, each just `items`/`isLoading`/`errorMessage` plus that + tab's own fetch/mutate methods. All three (plus Photos/Favorites) share + one shape: capture `session.sessionGeneration` at fetch entry, set + loading/clear error, fetch, check the generation before writing the + result, `notifyListeners()`. +- [`SyncStatusController`](../../lib/providers/sync_status_controller.dart) + — device-sync settings and live status (`syncStatusFor(item)`, + `syncHeaderStatus`, conflicts), subscribed to `SyncService`'s status + stream directly rather than living inside the same object as the item + lists it decorates. +- [`PickController`](../../lib/providers/pick_controller.dart) — "being + picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/ + `itemMatchesPickFilter`) — see `server.md`. +- [`ItemOperations`](../../lib/providers/item_operations.dart) — not a + `ChangeNotifier`; a plain, `const`-constructible class holding direct + references to `SessionController`/`FilesController`/`PhotosController`/ + `FavoritesController`. The one place that knows how a mutation + (delete/rename/move/copy/favorite-toggle/create-folder) ripples across + those three tabs' independent item lists — deliberately direct + references rather than a generic event bus, so a caller can `await` a + mutation and know every affected list has already been reconciled by the + time it returns, the same guarantee the old god object gave for free. + Also home to the file-details sheet's per-item pass-throughs + (shares/activity/versions/restore) — stateless, so they don't need a + controller of their own. -New **global** state belongs on `ServerProvider` as a private field + getter -+ a method that mutates it, calls `notifyListeners()`, and persists via -`_prefsFuture`. New **per-account** state follows the same shape but -persists through `_persistAccountPref` (namespaces the pref key under +`sessionGeneration` (on `SessionController`, read by every other +controller) is incremented on every account switch so an in-flight fetch +from the account just left can recognize it's stale and discard its result +instead of writing into the newly-active account's state — see `server.md` +for the full account-switch/storage story. + +New **global** state belongs on `SettingsController` as a private field + +getter + a method that mutates it, calls `notifyListeners()`, and persists +via its own prefs future. New **per-account** state follows the same shape +on whichever controller owns that domain, but persists through +`_persistAccountPref` (namespaces the pref key under `acct__...` via `AccountStore.accountPrefKey`) and must be -reset/reloaded in `_applyAccountPrefs` so it's correct after a switch. -Screen-local state (e.g. a `TextEditingController`, an expanded/collapsed -flag) stays in that view's own `State` class — see `standards.md` for the -split. +reset/reloaded on `SessionController`'s account-activated listener so it's +correct after a switch. Screen-local state (e.g. a `TextEditingController`, +an expanded/collapsed flag) stays in that view's own `State` class — see +`standards.md` for the split. -There's no separate repository/data layer: views call `ServerProvider` -methods directly, which call `NextcloudService`/`LoginFlowService`/ -`AccountStore`/`AppLockService`. +There's no separate repository/data layer: views call controller methods +directly, which call `NextcloudService`/`LoginFlowService`/`AccountStore`/ +`AppLockService`. ## Navigation / screen flow -`main.dart`'s `NextcloudApp` picks the app's `home` screen from provider -state, no named routes: +`main.dart`'s `NextcloudApp` picks the app's `home` screen from +`SessionController` state, no named routes: -- `provider.isRestoringSession` → `_SplashView` (monochrome app icon, +- `session.isRestoringSession` → `_SplashView` (monochrome app icon, tinted via `ColorFiltered` to the theme's `onSurface` so it works in both light/dark, plus a small spinner, while `flutter_secure_storage`/ `shared_preferences` are read on startup) -- else `!provider.isLoggedIn` → `LoginView` (server-address entry + Login +- else `!session.isLoggedIn` → `LoginView` (server-address entry + Login Flow v2) — `isLoggedIn` is only ever false here or after the last saved account is removed; switching between multiple saved accounts never routes through this screen (see `server.md`) -- else `provider.needsUnlock` → `LockScreenView` (login lock — see above) +- else `session.needsUnlock` → `LockScreenView` (login lock — see above) - else `MainShellView` `MainShellView` is a bottom-nav `IndexedStack` over up to 7 tabs — Files, Photos, Favorites, Activity, Trash, Shares, Recent — user-configurable (order, visibility up to `maxVisibleTabs`, default tab) via -`AppTab`/`ServerProvider` and rendered through `buildAppTabView` +`AppTab`/`SettingsController` and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the -total tab count, and `ServerProvider._enforceMaxVisibleTabs` already +total tab count, and `SettingsController._enforceMaxVisibleTabs` already auto-hides overflow on load (fresh install, or - as when Favorites was added - an existing saved tab order from before a new tab existed), so adding a tab to the `AppTab` enum needs no extra migration. @@ -125,7 +155,7 @@ result `Uri`s instead of a share intent's) rather than a separate in-app-only upload, so both entry points get the same destination picker and the same durable background-service upload. It similarly owns the pick-intent listener (`PickIntentService` - see `server.md` for the full "being picked by -another app" story) that feeds `ServerProvider.pickRequest`; while +another app" story) that feeds `PickController.pickRequest`; while `isPicking`, the visible tab list is overridden to just Files and Photos regardless of the user's own hidden/reordered tab settings, since those are the only two views that know how to handle a picking-mode tap and the @@ -170,9 +200,9 @@ The Move/Copy destination picker pushed from Files/Photos' selection toolbar - see `server.md` for the backend side) visually mirrors `ShareUploadView`'s browser the same way, but is a deliberately different case for state: it does **not** reuse -`ServerProvider`'s shared `currentFolderPath`/`pathStack`/`items`, and +`FilesController`'s shared `currentFolderPath`/`pathStack`/`items`, and owns its own local navigation state instead, fetching through the -stateless `ServerProvider.fetchFolderListing`/`applyFilesDisplayPrefs` +stateless `FilesController.fetchFolderListing`/`applyFilesDisplayPrefs` pair. `ShareUploadView` can get away with hijacking the shared state because it always resets to root on entry and pops all the way to the app's root route on completion - fine for a cold share-intent launch with diff --git a/.claude/context/server.md b/.claude/context/server.md index 44a88aa..580cb5d 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -35,9 +35,9 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart): credentials (the same class of bug the Custom-Tab-reuse issue above was, just recurring one layer down once everything moved to the owned WebView). -3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every +3. `SessionController` polls `LoginFlowService.poll(pollEndpoint, token)` every 2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in - `server_provider.dart`) until it gets a 200 with `server`/`loginName`/ + `session_controller.dart`) until it gets a 200 with `server`/`loginName`/ `appPassword`, a non-404 error, or a 10-minute timeout. A single dropped connection mid-poll (`http.ClientException`) is swallowed and retried on the next tick rather than aborting the whole flow. @@ -62,7 +62,7 @@ below. [`NextcloudService`](../../lib/services/nextcloud_service.dart) is the client for an authenticated session — constructed with `serverUrl` + `username` + the app password, one instance per login (held as -`ServerProvider.service`, recreated on every login/switch/logout). It's +`SessionController.service`, recreated on every login/switch/logout). It's effectively stateless per-instance (three final fields, headers rebuilt per request), which is what makes it trivial to have one saved per account rather than needing a rewrite for multi-account support. @@ -80,22 +80,24 @@ rather than needing a rewrite for multi-account support. for the Favorites tab) is WebDAV instead - the same `SEARCH` mechanism `fetchAllMedia`/`fetchRecentFiles` use, filtered by `oc:favorite` instead of mimetype/date. Favorites is a real tab - (`views/favorites_view.dart`/`ServerProvider.favoriteItems`/ - `fetchAllFavorites`/`_allFavorites`), not a filter toggle scoped to + (`views/favorites_view.dart`/`FavoritesController.items`/ + `fetchAll`/`_allFavorites`), not a filter toggle scoped to whatever folder the Files tab happens to be browsing (that's what it used to be - see the note below on why that didn't work). It shares Files' own sort/hidden/storage-scope/grid-list display prefs (`applyFilesDisplayPrefs`, also used by the Move/Copy destination picker) rather than a separate parallel settings dimension. Tapping a favorited folder switches to the Files tab, navigated there - (`navigateToAbsoluteFolder` + `requestTab`); a favorited file opens - directly from the Favorites tab itself. `deleteItem`/`renameItem`/move/ - copy all re-sync `_allFavorites` afterward via `_syncFavoritesIfLoaded` - (only once Favorites has actually been opened this session, tracked by - `_favoritesEverFetched`, so those actions don't pay for an extra request - on every edit for an account that's never visited the tab) since none of - them know how to patch `_allFavorites` in place the way - `toggleItemFavorite` does (added/removed/updated by id, right inline). + (`FilesController.navigateToAbsoluteFolder` + + `SettingsController.requestTab`); a favorited file opens directly from + the Favorites tab itself. `ItemOperations.deleteItem`/`renameItem`/move/ + copy all re-sync `FavoritesController._allFavorites` afterward via + `FavoritesController.syncIfLoaded` (only once Favorites has actually been + opened this session, tracked by `_everFetched`, so those actions don't + pay for an extra request on every edit for an account that's never + visited the tab) since none of them know how to patch `_allFavorites` in + place the way `ItemOperations.toggleItemFavorite` does (added/removed/ + updated by id, right inline via `FavoritesController.applyFavoriteToggle`). **Note**: this used to be a "favorites-only" filter toggle on the Files tab's controls row instead of its own tab, filtering the currently browsed folder's `_items`. That had two real bugs in sequence: first, @@ -144,9 +146,9 @@ rather than needing a rewrite for multi-account support. for a folder ("collection"), so no extra `Depth` header is needed. They return the raw HTTP status rather than a bool: `412 Precondition Failed` is WebDAV's standard signal for "something's already there" when - `Overwrite: F`, which is exactly the conflict `ServerProvider.moveItems`/ + `Overwrite: F`, which is exactly the conflict `ItemOperations.moveItems`/ `copyItems` need to detect without a separate existence-check request - per item. `ServerProvider` attempts every item in the batch first, + per item. `ItemOperations` attempts every item in the batch first, collects conflicts into `MoveCopyResult.conflicts` (`models/move_copy_result.dart`), and only then shows one summary (`MoveCopyConflictSheet`) instead of prompting per conflict as they're @@ -157,11 +159,11 @@ rather than needing a rewrite for multi-account support. (`views/move_copy_destination_picker.dart`) is covered in `architecture.md`, including why it can't reuse the Files tab's shared navigation state the way `ShareUploadView` does. -- **Uploads**: there's no more in-app-only upload path - `NextcloudService`/ - `ServerProvider.uploadFileFromPath` were removed once the Files tab's "+" - → "Upload File" was unified with the share-to-upload flow (below). Every - upload, however it's triggered, now goes through `ShareUploadView` - (caller-chosen destination via `navigateToAbsoluteFolder`, then +- **Uploads**: there's no more in-app-only upload path - `NextcloudService`'s + own `uploadFileFromPath` was removed once the Files tab's "+" → "Upload + File" was unified with the share-to-upload flow (below). Every upload, + however it's triggered, now goes through `ShareUploadView` (caller-chosen + destination via `FilesController.navigateToAbsoluteFolder`, then `UploadService`/`ShareUploadService.kt`). - **Receiving a shared file from another app**: hand-rolled in `MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, @@ -178,7 +180,7 @@ rather than needing a rewrite for multi-account support. story. The fix: `getInitialShare`/`onNewShare` only ever query cheap Uri metadata (name/size/mime, not content) so `ShareUploadView`'s destination picker - which mirrors the Files tab's own controls/filters/listing, - reusing the same `ServerProvider` fields and `widgets/item_icon.dart` - + reusing the same `FilesController` fields and `widgets/item_icon.dart` - always appears instantly regardless of file size. - **Uploading a shared file**: once the user picks a destination in `ShareUploadView`, [`UploadService`](../../lib/services/upload_service.dart) @@ -218,7 +220,7 @@ Google Drive/Instagram's "choose a file" flow), the reverse direction of ([`PickIntentService`](../../lib/services/pick_intent_service.dart)/ [`PickRequest`](../../lib/models/pick_request.dart)) - same cold-start-vs-already-running split as the share-intent channels. -- `ServerProvider.pickRequest`/`isPicking` drive picking mode app-wide once +- `PickController.pickRequest`/`isPicking` drive picking mode app-wide once `MainShellView` learns about a request at startup or via `onNewPickRequest`. While picking, `MainShellView` restricts the visible bottom-nav tabs to just Files and Photos (see `architecture.md`) - @@ -227,7 +229,7 @@ Google Drive/Instagram's "choose a file" flow), the reverse direction of open/select behavior (folders still navigate; a mime-mismatched file is rejected with a snackbar; matching files toggle-select or immediately confirm depending on `PickRequest.allowMultiple`). -- `ServerProvider.confirmPick` downloads the selected item(s) to a +- `PickController.confirmPick` downloads the selected item(s) to a `picker/` scratch subfolder in the app's cache dir (`downloadToFile`, same as any other download) - each item into its own `picker//` subfolder, keeping the on-disk filename as plain `item.name` rather than @@ -297,9 +299,9 @@ the whole engine is plain Kotlin using Android's WorkManager directly. from `SyncEngine`'s durable per-account state map (`SyncEngine.loadState(accountId).keys`) each time a snapshot is built, so there's one source of truth for "synced" instead of two that could - drift. `SyncService.getStatus()` (one-shot, seeds `ServerProvider` right - after login/account-switch) and `SyncService.statusStream` (live) both - return the same snapshot shape. `ServerProvider.syncHeaderStatus` + drift. `SyncService.getStatus()` (one-shot, seeds `SyncStatusController` + right after login/account-switch) and `SyncService.statusStream` (live) + both return the same snapshot shape. `SyncStatusController.syncHeaderStatus` (off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent chip/panel, replacing what used to be the WebDAV-refresh-loading indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict @@ -310,8 +312,8 @@ the whole engine is plain Kotlin using Android's WorkManager directly. shared companion function; `SyncConflictReceiver` (the notification action) and `MainActivity.kt`'s `resolveConflict` MethodChannel method (the sync header's "Keep local"/"Use server" buttons, - `ServerProvider.resolveSyncConflict`) both just call it, so there's one - resolution code path regardless of which surface triggered it. + `SyncStatusController.resolveSyncConflict`) both just call it, so there's + one resolution code path regardless of which surface triggered it. - **Conflicts are never auto-resolved.** The notification's "Keep local"/ "Use server" actions are `PendingIntent.getBroadcast`s (same shape as the Cancel action on upload/download notifications, just broadcast instead of @@ -329,12 +331,13 @@ the whole engine is plain Kotlin using Android's WorkManager directly. so changing the synced-folder list, the active account, or the Wi-Fi-only setting means cancelling and re-enqueueing, not updating in place. [`SyncService`](../../lib/services/sync_service.dart) (Dart) wraps this - - `ServerProvider` calls `reschedule` after every successful login/account - switch and every synced-folder/`syncOnCellular` change, and `cancel` on - logout/last-account-removed. The network constraint is - `NetworkType.UNMETERED` by default (`!syncOnCellular`, Wi-Fi only) or - `NetworkType.CONNECTED` if the user's opted into cellular sync. -- Synced-path list (`ServerProvider.syncedPaths` - files or folders, not + `SyncStatusController` calls `SyncService.reschedule` after every + successful login/account switch and every synced-folder/`syncOnCellular` + change, and `SyncService.cancel` on logout/last-account-removed. The + network constraint is `NetworkType.UNMETERED` by default + (`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's + opted into cellular sync. +- Synced-path list (`SyncStatusController.syncedPaths` - files or folders, not just folders despite the name of the underlying pref/native `Data` key, which stayed `ui_synced_folders`/`folders` to avoid a storage-key migration for a rename) follows the standard per-account-pref pattern @@ -383,7 +386,7 @@ the whole engine is plain Kotlin using Android's WorkManager directly. landed via `adb shell run-as`/a rooted shell, not a regular file manager UI. - **Already-synced files skip the network** in two places: - `ServerProvider.localSyncedFilePath(item)` is a pure function of the + `SyncStatusController.localSyncedFilePath(item)` is a pure function of the remote path (mirrors `SyncEngine.kt`'s `syncRoot` layout exactly, so Dart never needs to read the native sync-state `SharedPreferences`) that returns the local mirror path if it exists on disk. `ShareSheet`'s "Share @@ -394,7 +397,7 @@ the whole engine is plain Kotlin using Android's WorkManager directly. ## Multi-account storage & session persistence [`AccountStore`](../../lib/services/account_store.dart) owns everything -account-identity-related; `ServerProvider` owns everything about which +account-identity-related; `SessionController` owns everything about which account is *currently* live (see `architecture.md`). - **Per-account secrets**: one `flutter_secure_storage` key per account, @@ -413,26 +416,36 @@ account is *currently* live (see `architecture.md`). storage scope, show-hidden ×2, Photos sort field/ascending, Files' per-folder sort map, cache policy/interval — the full list is `AccountStore.perAccountPrefKeys`) are namespaced `acct__` - and reloaded on every switch via `ServerProvider._applyAccountPrefs`. + and owned by whichever controller that domain belongs to (e.g. + `FilesController` for grid/list/storage-scope/sort, + `PhotosController` for Photos' own sort/filter). Each reloads its own + slice in its `_onAccountActivated` listener, registered via + `SessionController.addAccountActivatedListener` in its constructor - + there's no longer one central method that reloads every domain's prefs at + once; `SessionController` just fires the notification, and every sibling + controller reacts independently. - **Legacy migration**: `AccountStore.migrateLegacyIfNeeded` runs once ever (guarded by the `account_migration_v1_done` flag), turning a pre-multi- account install's 3 flat secure-storage keys + flat browsing prefs into the first saved (and active) account, so upgrading users are never logged out. Never assume the legacy keys are gone — always check the migration flag rather than the keys' absence. -- On startup, `ServerProvider._init()` awaits the migration, loads the - account list + active id, then `_restoreSession()` looks up the active - account's password and calls `_applyCredentialsForAccount` (the renamed, - generation-guarded, account-aware version of what used to be - `_applyCredentials`) to rebuild the session without re-hitting the login - flow. +- On startup, `SessionController`'s constructor awaits the migration, loads + the account list + active id, then `_restoreSession()` looks up the + active account's password and calls `_applyCredentialsForAccount` + (generation-guarded, account-aware) to rebuild the session without + re-hitting the login flow - firing `_notifyAccountActivated()` on + success, which is what triggers every sibling controller's own initial + fetch. - **Switching accounts** (`switchAccount`/`cycleToNextAccount`/ `cycleToPreviousAccount`/`removeAccount`'s fallback, plus landing on a freshly-added account) all funnel through the single `_activateAccount` - engine: bump `_sessionGeneration`, cancel any pending login flow, clear - every content field *without* ever setting `isLoggedIn` false (that's the - detail that keeps `main.dart`'s root routing from bouncing through - `LoginView` mid-switch), reload the target account's prefs, then verify + engine: bump `sessionGeneration`, cancel any pending login flow, call + `_notifyAccountCleared()` (every sibling controller's registered + `addAccountClearedListener` callback resets that controller's own state) + *without* ever setting `isLoggedIn` false (that's the detail that keeps + `main.dart`'s root routing from bouncing through `LoginView` mid-switch), + then verify its credentials and refetch everything. This is a full teardown-and-reload every time — there is deliberately no simultaneous multi-account state or background sync; only one account's content is ever live. @@ -454,7 +467,7 @@ account is *currently* live (see `architecture.md`). that's actually destructive/irreversible - UI call sites (`AccountView`) gate it behind a confirmation dialog; `logout()` doesn't need one. - Any UI code that calls either and might have ended the session should - check `!provider.isLoggedIn` afterward and `Navigator.popUntil((r) => + check `!session.isLoggedIn` afterward and `Navigator.popUntil((r) => r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left stranded over a root route that's silently swapped to `LoginView` underneath it. Don't pop unconditionally — removing a *non-active* @@ -470,7 +483,7 @@ app. [`AppLockService`](../../lib/services/app_lock_service.dart) wraps `authenticate()` always delegates to whatever the OS already has configured (biometric, or device PIN/pattern/password as fallback, via `biometricOnly: false`). Never build a custom in-app PIN screen for this — -extend `AppLockService`/the `ServerProvider` gates described in +extend `AppLockService`/the `SessionController` gates described in `architecture.md` instead. **Android native requirements** (both already done, keep them if you touch diff --git a/.claude/context/standards.md b/.claude/context/standards.md index b56a8ce..0d22ae5 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -29,28 +29,35 @@ class/method already makes obvious. - Private helpers/widgets are prefixed with `_` and live in the same file as their one caller; promote to `widgets/` only once something is reused across files. -- Read provider state with `context.watch()` in `build`, - and `context.read()` for one-off calls from callbacks - (matches `LoginView._handleContinue`). +- Read controller state with `context.watch()` in `build`, and + `context.read()` for one-off calls from callbacks (matches + `LoginView._handleContinue`). Pull in only the specific controller(s) a + widget actually needs (e.g. `FilesController` + `SyncStatusController` + for a Files tile), not a catch-all — see `architecture.md`'s "State + management" section for the full controller split and what each one owns. -## `ServerProvider` conventions +## Controller conventions - Any method that fetches data and writes it into a shared field - (`refreshData`, `fetchAllMedia`, `fetchTrash`, `fetchShares`, - `fetchRecent`, `_applyCredentialsForAccount`) must guard against a stale - write from an account the user has since switched away from: capture - `final gen = _sessionGeneration;` at entry, and check - `if (gen != _sessionGeneration) return;` immediately after each `await` - before touching any field or calling `notifyListeners()`. Follow this - pattern for any new fetch method added to the provider. -- New persisted state on `ServerProvider` must be classified global vs. - per-account (see `architecture.md`/`server.md`) up front — global state - uses a plain `_prefsFuture.then((p) => p.setX(key, value))`; per-account - state goes through `_persistAccountPref(key, (p, namespacedKey) => - p.setX(namespacedKey, value))` and must also be handled in - `_applyAccountPrefs` (both the "reset to default when no account" and the - "load for this account" branches) so it's correct immediately after a - switch, not just at startup. + (`FilesController.refreshData`, `PhotosController.fetchAllMedia`, + `TrashController.fetchAll`, `SharesController.fetchAll`, + `RecentController.fetchAll`, `SessionController._applyCredentialsForAccount`) + must guard against a stale write from an account the user has since + switched away from: capture `final gen = session.sessionGeneration;` at + entry, and check `if (gen != session.sessionGeneration) return;` + immediately after each `await` before touching any field or calling + `notifyListeners()`. Follow this pattern for any new fetch method added + to any controller. +- New persisted state must be classified global vs. per-account (see + `architecture.md`/`server.md`) up front and live on whichever controller + owns that domain — global state (`SettingsController`) uses a plain + `_prefsFuture.then((p) => p.setX(key, value))`; per-account state goes + through `_persistAccountPref(key, (p, namespacedKey) => + p.setX(namespacedKey, value))` and must also be handled in that + controller's own `_onAccountCleared`/`_onAccountActivated` listeners + (registered via `SessionController.addAccountClearedListener`/ + `addAccountActivatedListener` in the controller's constructor) so it's + correct immediately after a switch, not just at startup. ## Testing @@ -59,7 +66,7 @@ class/method already makes obvious. `MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler — and disable Google Fonts network fetching (`GoogleFonts.config.allowRuntimeFetching = false`) in `setUpAll`. Without - these, `ServerProvider`'s session restore never resolves in the test + these, `SessionController`'s session restore never resolves in the test sandbox (no plugin implementation is registered, so the read future just never completes) and the app stays on `_SplashView`'s indeterminate spinner, which makes `pumpAndSettle()` hang until its own timeout instead diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 05db049..bc66919 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -57,15 +57,15 @@ widgets. Key points: new blur/shadow combo. - [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the main tab bar; opacity/blur are user-adjustable settings - (`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull - new adjustable visual knobs from the provider the same way rather than - hardcoding them. + (`SettingsController.bottomBarOpacity`/`bottomBarBlur`), not constants — + pull new adjustable visual knobs from `SettingsController` the same way + rather than hardcoding them. - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see `architecture.md`); also where the pull-to-refresh gesture thresholds and the classic Material refresh spinner live. Its persistent chip/panel (icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync - status (`ServerProvider.syncHeaderStatus`), not the WebDAV-refresh + status (`SyncStatusController.syncHeaderStatus`), not the WebDAV-refresh loading state the pull gesture itself triggers - that has its own, separate floating spinner bubble, so nothing was lost by handing the persistent text/icon over. diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/DavTransfer.kt b/android/app/src/main/kotlin/dev/ayushya/noo/DavTransfer.kt new file mode 100644 index 0000000..f7a7bda --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/DavTransfer.kt @@ -0,0 +1,140 @@ +package dev.ayushya.noo + +import java.io.File +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * Shared low-level WebDAV GET/PUT primitives (plain `HttpURLConnection`, no + * new HTTP dependency) - used by [DownloadService], [ShareUploadService], + * and [SyncEngine], which each independently re-implemented this same + * ~20-line connect/stream/disconnect shape before this existed. Progress + * reporting and cancellation are optional: the two foreground Services + * pass both (user-visible, cancellable transfers); [SyncEngine]'s + * background callers pass neither. + * + * Deliberately does *not* know about `OCS-APIRequest` - that header is for + * OCS REST endpoints (shares/activity/quota), not plain WebDAV GET/PUT, + * and was previously sent by mistake on some of these calls; dropped here + * so every caller gets the same (correct) minimal header set. + */ +object DavTransfer { + const val CONNECT_TIMEOUT_MS = 15000 + const val READ_TIMEOUT_MS = 30000 + const val UPLOAD_READ_TIMEOUT_MS = 60000 + private const val BUFFER_SIZE = 256 * 1024 + private const val PROGRESS_THROTTLE_MS = 200 + + /** GETs [url] into [destination] (a plain file), creating parent dirs as needed. */ + fun get( + url: URL, + authHeader: String, + destination: File, + onProgress: ((received: Long, total: Long?) -> Unit)? = null, + isCancelled: (() -> Boolean)? = null, + ): Boolean { + destination.parentFile?.mkdirs() + return getInto(url, authHeader, { destination.outputStream() }, onProgress, isCancelled) + } + + /** + * GETs [url] into whatever [openOutput] returns - a plain `File` output + * stream or, for [DownloadService]'s MediaStore-staged entries, a + * `ContentResolver`-provided one the caller owns. + */ + fun getInto( + url: URL, + authHeader: String, + openOutput: () -> OutputStream?, + onProgress: ((received: Long, total: Long?) -> Unit)? = null, + isCancelled: (() -> Boolean)? = null, + ): Boolean { + val connection = url.openConnection() as HttpURLConnection + return try { + connection.requestMethod = "GET" + connection.setRequestProperty("Authorization", authHeader) + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.connect() + if (connection.responseCode !in 200..299) return false + + val total = connection.contentLengthLong.takeIf { it > 0 } + val output = openOutput() ?: return false + output.use { out -> + connection.inputStream.use { input -> + copyWithProgress(input, out, total, onProgress, isCancelled) + } + } + isCancelled?.invoke() != true + } catch (e: Exception) { + false + } finally { + connection.disconnect() + } + } + + /** PUTs [source]'s bytes to [url]. */ + fun put( + url: URL, + authHeader: String, + source: File, + contentType: String? = null, + onProgress: ((sent: Long, total: Long) -> Unit)? = null, + isCancelled: (() -> Boolean)? = null, + ): Boolean { + val length = source.length() + val connection = url.openConnection() as HttpURLConnection + return try { + connection.requestMethod = "PUT" + connection.doOutput = true + connection.setFixedLengthStreamingMode(length) + connection.setRequestProperty("Authorization", authHeader) + if (contentType != null) connection.setRequestProperty("Content-Type", contentType) + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = UPLOAD_READ_TIMEOUT_MS + + connection.outputStream.use { output -> + source.inputStream().use { input -> + copyWithProgress(input, output, length, { sent, total -> + onProgress?.invoke(sent, total ?: length) + }, isCancelled) + } + } + if (isCancelled?.invoke() == true) { + false + } else { + connection.responseCode in 200..299 + } + } catch (e: Exception) { + false + } finally { + connection.disconnect() + } + } + + private fun copyWithProgress( + input: java.io.InputStream, + output: OutputStream, + total: Long?, + onProgress: ((Long, Long?) -> Unit)?, + isCancelled: (() -> Boolean)?, + ) { + val buffer = ByteArray(BUFFER_SIZE) + var transferred = 0L + var lastEmit = 0L + while (isCancelled?.invoke() != true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + transferred += read + if (onProgress != null) { + val now = System.currentTimeMillis() + if (now - lastEmit >= PROGRESS_THROTTLE_MS) { + lastEmit = now + onProgress(transferred, total) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt index 611f016..a6660ae 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt @@ -1,7 +1,6 @@ package dev.ayushya.noo import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service @@ -18,24 +17,22 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import org.json.JSONArray import java.io.File -import java.net.HttpURLConnection import java.net.URL -import java.util.concurrent.atomic.AtomicBoolean /** * Foreground service that downloads one or more files over WebDAV straight * into the device's public Downloads folder, independent of MainActivity/ * the Flutter engine being alive - the download counterpart of - * ShareUploadService.kt (see its doc comment for the full rationale: only - * a real Android Service survives the app being closed mid-transfer, the - * same guarantee a real file-manager app's download notification gives - * you). Re-implements a plain WebDAV GET here in Kotlin for the same - * reason uploads do - keep NextcloudService.downloadToFile in sync if - * download semantics change. + * ShareUploadService.kt (see its doc comment: only a real Android Service + * survives the app being closed mid-transfer, the same guarantee a real + * file-manager app's download notification gives you). GET itself is + * [DavTransfer.getInto]; this file owns only what's specific to + * downloading - MediaStore staging and the notification/queue plumbing. * * Started via the `dev.ayushya.noo/download_service` MethodChannel * (MainActivity.kt). Shows one persistent, cancellable notification for - * the whole batch, same UX as ShareUploadService's. + * the whole batch; a second batch arriving mid-download queues behind the + * first (see [TransferQueue]) instead of being dropped. */ class DownloadService : Service() { companion object { @@ -49,9 +46,6 @@ class DownloadService : Service() { private const val NOTIFICATION_ID = 4301 } - private val cancelled = AtomicBoolean(false) - private var downloadThread: Thread? = null - private data class DownloadFile( val path: String, val name: String, @@ -59,11 +53,23 @@ class DownloadService : Service() { val size: Long?, ) + private data class DownloadBatch( + val files: List, + val serverUrl: String, + val username: String, + val authHeader: String, + ) + + @Volatile + private var cancelled = false + private val queue = TransferQueue { runDownloads(it) } + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_CANCEL) { - cancelled.set(true) + cancelled = true + queue.clearPending() return START_NOT_STICKY } @@ -76,7 +82,13 @@ class DownloadService : Service() { return START_NOT_STICKY } - createNotificationChannel() + NooNotificationChannels.ensure( + this, + CHANNEL_ID, + "File downloads", + NotificationManager.IMPORTANCE_LOW, + "Progress for files downloaded from Noo", + ) ServiceCompat.startForeground( this, NOTIFICATION_ID, @@ -88,16 +100,7 @@ class DownloadService : Service() { }, ) - // A second batch arriving mid-download is dropped rather than - // queued - same tradeoff ShareUploadService makes, rare in - // practice and not worth a real queue for. - if (downloadThread == null) { - val files = parseFiles(filesJson) - downloadThread = Thread { - runDownloads(files, serverUrl, username, authHeader) - }.also { it.start() } - } - + queue.enqueue(DownloadBatch(parseFiles(filesJson), serverUrl, username, authHeader)) return START_NOT_STICKY } @@ -114,25 +117,25 @@ class DownloadService : Service() { } } - private fun runDownloads( - files: List, - serverUrl: String, - username: String, - authHeader: String, - ) { + private fun runDownloads(batch: DownloadBatch) { + cancelled = false var succeeded = 0 var failed = 0 - val cleanServer = serverUrl.trimEnd('/') + val cleanServer = batch.serverUrl.trimEnd('/') - for ((index, file) in files.withIndex()) { - if (cancelled.get()) break - val label = if (files.size == 1) file.name else "${file.name} (${index + 1}/${files.size})" + for ((index, file) in batch.files.withIndex()) { + if (cancelled) break + val label = if (batch.files.size == 1) { + file.name + } else { + "${file.name} (${index + 1}/${batch.files.size})" + } try { var cleanPath = file.path.trim() if (!cleanPath.startsWith("/")) cleanPath = "/$cleanPath" val encodedPath = cleanPath.split("/").joinToString("/") { Uri.encode(it) } - val url = URL("$cleanServer/remote.php/dav/files/$username$encodedPath") - val ok = downloadFile(file, url, authHeader) { received, total -> + val url = URL("$cleanServer/remote.php/dav/files/${batch.username}$encodedPath") + val ok = downloadFile(file, url, batch.authHeader) { received, total -> notify( buildProgressNotification( "Downloading $label…", @@ -147,17 +150,18 @@ class DownloadService : Service() { } } - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val finalText = when { - cancelled.get() -> "Download cancelled" - failed == 0 && files.size == 1 -> "Downloaded ${files.first().name}" - failed == 0 -> "Downloaded $succeeded of ${files.size} files" - else -> "Downloaded $succeeded of ${files.size} files - $failed failed" + cancelled -> "Download cancelled" + failed == 0 && batch.files.size == 1 -> "Downloaded ${batch.files.first().name}" + failed == 0 -> "Downloaded $succeeded of ${batch.files.size} files" + else -> "Downloaded $succeeded of ${batch.files.size} files - $failed failed" } - manager.notify(NOTIFICATION_ID, buildFinalNotification(finalText)) + manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText)) - stopForeground(STOP_FOREGROUND_DETACH) - stopSelf() + if (queue.pendingCount == 0) { + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } } private fun progressFraction(received: Long, total: Long?): Float? { @@ -177,57 +181,32 @@ class DownloadService : Service() { authHeader: String, onProgress: (Long, Long?) -> Unit, ): Boolean { - val connection = url.openConnection() as HttpURLConnection - return try { - connection.requestMethod = "GET" - connection.setRequestProperty("Authorization", authHeader) - connection.setRequestProperty("OCS-APIRequest", "true") - connection.connectTimeout = 15000 - connection.readTimeout = 30000 - connection.connect() - - if (connection.responseCode !in 200..299) return false - - val total = file.size ?: connection.contentLengthLong.takeIf { it > 0 } - val mimeType = file.mimeType ?: "application/octet-stream" - val outputUri = createDownloadsEntry(file.name, mimeType) ?: return false - - val stream = contentResolver.openOutputStream(outputUri) ?: return false - stream.use { output -> - connection.inputStream.use { input -> - val buffer = ByteArray(256 * 1024) - var received = 0L - var lastEmit = 0L - while (!cancelled.get()) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) - received += read - val now = System.currentTimeMillis() - if (now - lastEmit >= 200) { - lastEmit = now - onProgress(received, total) - } - } + val mimeType = file.mimeType ?: "application/octet-stream" + var outputUri: Uri? = null + val ok = DavTransfer.getInto( + url, + authHeader, + openOutput = { + val uri = createDownloadsEntry(file.name, mimeType) + if (uri != null) { + outputUri = uri + contentResolver.openOutputStream(uri) + } else { + null } - } + }, + onProgress = { received, total -> onProgress(received, file.size ?: total) }, + isCancelled = { cancelled }, + ) + outputUri?.let { uri -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val values = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) } - contentResolver.update(outputUri, values, null, null) + contentResolver.update(uri, values, null, null) } - - if (cancelled.get()) { - contentResolver.delete(outputUri, null, null) - false - } else { - true - } - } catch (e: Exception) { - false - } finally { - connection.disconnect() + if (!ok) contentResolver.delete(uri, null, null) } + return ok } private fun createDownloadsEntry(name: String, mimeType: String): Uri? { @@ -247,21 +226,11 @@ class DownloadService : Service() { } } - private fun notify(notification: Notification) { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.notify(NOTIFICATION_ID, notification) - } + private fun manager() = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "File downloads", - NotificationManager.IMPORTANCE_LOW, - ).apply { description = "Progress for files downloaded from Noo" } - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } + private fun notify(notification: Notification) { + manager().notify(NOTIFICATION_ID, notification) } private fun buildProgressNotification( @@ -301,7 +270,7 @@ class DownloadService : Service() { } override fun onDestroy() { - cancelled.set(true) + cancelled = true super.onDestroy() } } diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt b/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt new file mode 100644 index 0000000..e5e026a --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt @@ -0,0 +1,29 @@ +package dev.ayushya.noo + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +/** + * Shared notification-channel creation - [DownloadService], + * [ShareUploadService], and [SyncWorker] each independently re-implemented + * this same `if (SDK_INT >= O) { NotificationChannel(...); create }` shape + * before this existed. + */ +object NooNotificationChannels { + fun ensure( + context: Context, + id: String, + name: String, + importance: Int, + description: String, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel(id, name, importance).apply { + this.description = description + } + manager.createNotificationChannel(channel) + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt index f9cc191..27c16c5 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt @@ -1,7 +1,6 @@ package dev.ayushya.noo import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service @@ -16,9 +15,7 @@ import androidx.core.app.ServiceCompat import org.json.JSONArray import java.io.File import java.io.FileOutputStream -import java.net.HttpURLConnection import java.net.URL -import java.util.concurrent.atomic.AtomicBoolean /** * Foreground service that prepares (copies from a content:// Uri) and @@ -28,19 +25,18 @@ import java.util.concurrent.atomic.AtomicBoolean * doesn't interrupt the upload, the same way a file-manager app's own * upload notification survives the app being closed. * - * Re-implements a plain WebDAV PUT here in Kotlin (HttpURLConnection, no - * new HTTP dependency) rather than reusing NextcloudService/Dio from Dart, - * since a Dart isolate doesn't keep running once the Flutter engine/ - * Activity are gone - only a real Android Service does. This does mean the - * PUT request itself is duplicated logic (see NextcloudService. - * uploadFileFromPath); keep both in sync if the upload semantics change. + * PUT itself is [DavTransfer.put]; this file owns only what's specific to + * a share-upload - materializing the source content:// Uri into a real + * file first (WebDAV PUT needs a known Content-Length, which streaming + * straight from a content:// Uri can't always provide) and the + * notification/queue plumbing. * * Started via the `dev.ayushya.noo/upload_service` MethodChannel * (MainActivity.kt) with credentials/destination passed as Intent extras - * never has an Activity in the loop after that. Shows one persistent, - * cancellable notification for the whole batch; the Cancel action re-enters - * this same running service instance with [ACTION_CANCEL], which the - * copy/upload loops poll. + * cancellable notification for the whole batch; a second share arriving + * mid-upload queues behind the first (see [TransferQueue]) instead of + * being dropped. */ class ShareUploadService : Service() { companion object { @@ -55,16 +51,26 @@ class ShareUploadService : Service() { private const val NOTIFICATION_ID = 4201 } - private val cancelled = AtomicBoolean(false) - private var uploadThread: Thread? = null - private data class ShareFile(val uri: String, val name: String, val size: Long?) + private data class UploadBatch( + val files: List, + val serverUrl: String, + val username: String, + val authHeader: String, + val remoteFolder: String, + ) + + @Volatile + private var cancelled = false + private val queue = TransferQueue { runUploads(it) } + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_CANCEL) { - cancelled.set(true) + cancelled = true + queue.clearPending() return START_NOT_STICKY } @@ -80,7 +86,13 @@ class ShareUploadService : Service() { return START_NOT_STICKY } - createNotificationChannel() + NooNotificationChannels.ensure( + this, + CHANNEL_ID, + "File uploads", + NotificationManager.IMPORTANCE_LOW, + "Progress for files shared to Noo", + ) ServiceCompat.startForeground( this, NOTIFICATION_ID, @@ -92,16 +104,7 @@ class ShareUploadService : Service() { }, ) - // A second share arriving mid-upload is dropped rather than queued - - // rare in practice (sharing again before the first batch finishes), - // not worth a real queue for. - if (uploadThread == null) { - val files = parseFiles(filesJson) - uploadThread = Thread { - runUploads(files, serverUrl, username, authHeader, remoteFolder) - }.also { it.start() } - } - + queue.enqueue(UploadBatch(parseFiles(filesJson), serverUrl, username, authHeader, remoteFolder)) return START_NOT_STICKY } @@ -117,23 +120,22 @@ class ShareUploadService : Service() { } } - private fun runUploads( - files: List, - serverUrl: String, - username: String, - authHeader: String, - remoteFolder: String, - ) { + private fun runUploads(batch: UploadBatch) { + cancelled = false var succeeded = 0 var failed = 0 - val cleanServer = serverUrl.trimEnd('/') - var cleanFolder = remoteFolder.trim() + val cleanServer = batch.serverUrl.trimEnd('/') + var cleanFolder = batch.remoteFolder.trim() if (!cleanFolder.startsWith("/")) cleanFolder = "/$cleanFolder" if (!cleanFolder.endsWith("/")) cleanFolder = "$cleanFolder/" - for ((index, file) in files.withIndex()) { - if (cancelled.get()) break - val label = if (files.size == 1) file.name else "${file.name} (${index + 1}/${files.size})" + for ((index, file) in batch.files.withIndex()) { + if (cancelled) break + val label = if (batch.files.size == 1) { + file.name + } else { + "${file.name} (${index + 1}/${batch.files.size})" + } var tempFile: File? = null try { notify(buildProgressNotification("Preparing $label…", null, indeterminate = true)) @@ -146,19 +148,26 @@ class ShareUploadService : Service() { ), ) } - if (cancelled.get()) break + if (cancelled) break val encodedName = Uri.encode(file.name) - val url = URL("$cleanServer/remote.php/dav/files/$username$cleanFolder$encodedName") - val ok = uploadFile(tempFile, url, authHeader) { sent, total -> - notify( - buildProgressNotification( - "Uploading $label…", - progressFraction(sent, total), - indeterminate = false, - ), - ) - } + val url = URL("$cleanServer/remote.php/dav/files/${batch.username}$cleanFolder$encodedName") + val ok = DavTransfer.put( + url, + batch.authHeader, + tempFile, + contentType = "application/octet-stream", + onProgress = { sent, total -> + notify( + buildProgressNotification( + "Uploading $label…", + progressFraction(sent, total), + indeterminate = false, + ), + ) + }, + isCancelled = { cancelled }, + ) if (ok) succeeded++ else failed++ } catch (e: Exception) { failed++ @@ -167,17 +176,18 @@ class ShareUploadService : Service() { } } - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val finalText = when { - cancelled.get() -> "Upload cancelled" - failed == 0 && files.size == 1 -> "Uploaded ${files.first().name}" - failed == 0 -> "Uploaded $succeeded of ${files.size} files" - else -> "Uploaded $succeeded of ${files.size} files - $failed failed" + cancelled -> "Upload cancelled" + failed == 0 && batch.files.size == 1 -> "Uploaded ${batch.files.first().name}" + failed == 0 -> "Uploaded $succeeded of ${batch.files.size} files" + else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed" } - manager.notify(NOTIFICATION_ID, buildFinalNotification(finalText)) + manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText)) - stopForeground(STOP_FOREGROUND_DETACH) - stopSelf() + if (queue.pendingCount == 0) { + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } } private fun progressFraction(sent: Long, total: Long?): Float? { @@ -193,7 +203,7 @@ class ShareUploadService : Service() { val buffer = ByteArray(256 * 1024) var sent = 0L var lastEmit = 0L - while (!cancelled.get()) { + while (!cancelled) { val read = input.read(buffer) if (read == -1) break output.write(buffer, 0, read) @@ -209,70 +219,11 @@ class ShareUploadService : Service() { return target } - private fun uploadFile( - file: File, - url: URL, - authHeader: String, - onProgress: (Long, Long) -> Unit, - ): Boolean { - val length = file.length() - val connection = url.openConnection() as HttpURLConnection - return try { - connection.requestMethod = "PUT" - connection.doOutput = true - connection.setFixedLengthStreamingMode(length) - connection.setRequestProperty("Authorization", authHeader) - connection.setRequestProperty("OCS-APIRequest", "true") - connection.setRequestProperty("Content-Type", "application/octet-stream") - connection.connectTimeout = 15000 - connection.readTimeout = 30000 - - connection.outputStream.use { output -> - file.inputStream().use { input -> - val buffer = ByteArray(256 * 1024) - var sent = 0L - var lastEmit = 0L - while (!cancelled.get()) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) - sent += read - val now = System.currentTimeMillis() - if (now - lastEmit >= 200) { - lastEmit = now - onProgress(sent, length) - } - } - } - } - if (cancelled.get()) { - false - } else { - val code = connection.responseCode - code == 201 || code == 204 || code == 200 - } - } catch (e: Exception) { - false - } finally { - connection.disconnect() - } - } + private fun manager() = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private fun notify(notification: Notification) { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.notify(NOTIFICATION_ID, notification) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "File uploads", - NotificationManager.IMPORTANCE_LOW, - ).apply { description = "Progress for files shared to Noo" } - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } + manager().notify(NOTIFICATION_ID, notification) } private fun buildProgressNotification(text: String, progress: Float?, indeterminate: Boolean): Notification { @@ -308,7 +259,7 @@ class ShareUploadService : Service() { } override fun onDestroy() { - cancelled.set(true) + cancelled = true super.onDestroy() } } diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt index 6cd50c1..377536b 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt @@ -10,7 +10,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.io.File -import java.net.HttpURLConnection import java.net.URL import java.text.SimpleDateFormat import java.util.Locale @@ -258,33 +257,17 @@ object SyncEngine { remotePath: String, destination: File, ): Boolean { - val encodedPath = remotePath.split("/").joinToString("/") { Uri.encode(it) } - val url = URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath") - val connection = url.openConnection() as HttpURLConnection - return try { - connection.requestMethod = "GET" - connection.setRequestProperty("Authorization", authHeader) - connection.connectTimeout = 15000 - connection.readTimeout = 30000 - connection.connect() - val code = connection.responseCode - if (code !in 200..299) { - Log.w(TAG, "GET $url failed: $code") - return false - } - - destination.parentFile?.mkdirs() - connection.inputStream.use { input -> - destination.outputStream().use { output -> input.copyTo(output) } - } - Log.d(TAG, "GET $url -> saved to ${destination.absolutePath} (${destination.length()} bytes)") - true - } catch (e: Exception) { - Log.e(TAG, "GET $url threw", e) - false - } finally { - connection.disconnect() - } + val url = davUrl(serverUrl, username, remotePath) + val ok = DavTransfer.get(url, authHeader, destination) + Log.d( + TAG, + if (ok) { + "GET $url -> saved to ${destination.absolutePath} (${destination.length()} bytes)" + } else { + "GET $url failed" + }, + ) + return ok } fun uploadFile( @@ -294,26 +277,13 @@ object SyncEngine { remotePath: String, source: File, ): Boolean { + val url = davUrl(serverUrl, username, remotePath) + return DavTransfer.put(url, authHeader, source) + } + + private fun davUrl(serverUrl: String, username: String, remotePath: String): URL { val encodedPath = remotePath.split("/").joinToString("/") { Uri.encode(it) } - val url = URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath") - val connection = url.openConnection() as HttpURLConnection - return try { - connection.requestMethod = "PUT" - connection.setRequestProperty("Authorization", authHeader) - connection.setFixedLengthStreamingMode(source.length()) - connection.doOutput = true - connection.connectTimeout = 15000 - connection.readTimeout = 60000 - connection.connect() - source.inputStream().use { input -> - connection.outputStream.use { output -> input.copyTo(output) } - } - connection.responseCode in 200..299 - } catch (e: Exception) { - false - } finally { - connection.disconnect() - } + return URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath") } /** diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt index 78767c1..0191d02 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt @@ -1,12 +1,10 @@ package dev.ayushya.noo import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker @@ -260,13 +258,12 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private fun createChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Device sync", - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { description = "Updates and conflicts for folders synced to this device" } - manager().createNotificationChannel(channel) - } + NooNotificationChannels.ensure( + applicationContext, + CHANNEL_ID, + "Device sync", + NotificationManager.IMPORTANCE_DEFAULT, + "Updates and conflicts for folders synced to this device", + ) } } diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/TransferQueue.kt b/android/app/src/main/kotlin/dev/ayushya/noo/TransferQueue.kt new file mode 100644 index 0000000..6aebb41 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/TransferQueue.kt @@ -0,0 +1,41 @@ +package dev.ayushya.noo + +import java.util.concurrent.LinkedBlockingQueue + +/** + * A tiny sequential work queue for [DownloadService]/[ShareUploadService]: + * each enqueued batch runs to completion before the next starts, instead + * of a second batch arriving mid-transfer being silently dropped - the + * previous behavior both services independently accepted as a known + * limitation (see their old doc comments). One daemon worker thread, + * started lazily on first use and parked on the queue for the life of the + * process. + */ +class TransferQueue(private val process: (T) -> Unit) { + private val queue = LinkedBlockingQueue() + + @Volatile + private var started = false + + /** How many batches (including whatever's currently running) are queued. */ + val pendingCount: Int + get() = queue.size + + @Synchronized + fun enqueue(batch: T) { + queue.put(batch) + if (!started) { + started = true + Thread { + while (true) { + process(queue.take()) + } + }.apply { isDaemon = true }.start() + } + } + + /** Drops every batch that hasn't started running yet (not the current one). */ + fun clearPending() { + queue.clear() + } +} diff --git a/lib/main.dart b/lib/main.dart index 675b901..3892de1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,17 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'models/app_tab.dart'; import 'models/pick_request.dart'; -import 'providers/server_provider.dart'; +import 'providers/favorites_controller.dart'; +import 'providers/files_controller.dart'; +import 'providers/item_operations.dart'; +import 'providers/photos_controller.dart'; +import 'providers/pick_controller.dart'; +import 'providers/recent_controller.dart'; +import 'providers/session_controller.dart'; +import 'providers/settings_controller.dart'; +import 'providers/shares_controller.dart'; +import 'providers/sync_status_controller.dart'; +import 'providers/trash_controller.dart'; import 'services/pick_intent_service.dart'; import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; @@ -20,8 +30,51 @@ import 'widgets/floating_bottom_bar.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); runApp( - ChangeNotifierProvider( - create: (_) => ServerProvider(), + MultiProvider( + // Split out of the former single `ServerProvider` god object - see + // `.claude/context/architecture.md`'s "State management" section. + // Order matters: each `create` below reads earlier providers via + // `context.read`, which is safe here since none of them are ever + // 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: (context) => SyncStatusController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => FilesController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => + PhotosController(context.read(), context.read()), + ), + ChangeNotifierProvider( + create: (context) => + FavoritesController(context.read(), context.read()), + ), + ChangeNotifierProvider( + create: (context) => TrashController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => SharesController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => RecentController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => PickController(context.read()), + ), + Provider( + create: (context) => ItemOperations( + session: context.read(), + files: context.read(), + photos: context.read(), + favorites: context.read(), + ), + ), + ], child: const NextcloudApp(), ), ); @@ -32,7 +85,8 @@ class NextcloudApp extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); + final settings = context.watch(); + final session = context.watch(); return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { @@ -40,22 +94,22 @@ class NextcloudApp extends StatelessWidget { title: 'Noo', debugShowCheckedModeBanner: false, theme: AppTheme.light( - provider.seedColor, + settings.seedColor, dynamicScheme: lightDynamic, - useDynamicColor: provider.useDynamicColor, + useDynamicColor: settings.useDynamicColor, ), darkTheme: AppTheme.dark( - provider.seedColor, + settings.seedColor, dynamicScheme: darkDynamic, - useDynamicColor: provider.useDynamicColor, - amoled: provider.amoledDark, + useDynamicColor: settings.useDynamicColor, + amoled: settings.amoledDark, ), - themeMode: provider.themeMode, - home: provider.isRestoringSession + themeMode: settings.themeMode, + home: session.isRestoringSession ? const _SplashView() - : (!provider.isLoggedIn + : (!session.isLoggedIn ? const LoginView() - : (provider.needsUnlock + : (session.needsUnlock ? const LockScreenView() : const MainShellView())), ); @@ -128,7 +182,7 @@ class _MainShellViewState extends State { // Preferences load asynchronously, but in practice finish well before // session restore (which also has to hit the network) does, so by the // time this shell mounts `defaultTab` already reflects the saved value. - _currentTab = context.read().defaultTab; + _currentTab = context.read().defaultTab; // Handles both a cold start via another app's "Share to..." sheet // (getInitialShare) and a share arriving while the app is already @@ -143,11 +197,11 @@ class _MainShellViewState extends State { // another app may have launched Noo as its GET_CONTENT picker. PickIntentService.getPickRequest().then((request) { if (request != null && mounted) { - context.read().setPickRequest(request); + context.read().setPickRequest(request); } }); _pickSub = PickIntentService.onNewPickRequest.listen((request) { - if (mounted) context.read().setPickRequest(request); + if (mounted) context.read().setPickRequest(request); }); WidgetsBinding.instance.addPostFrameCallback( @@ -220,20 +274,21 @@ class _MainShellViewState extends State { @override Widget build(BuildContext context) { - final provider = context.watch(); + final settings = context.watch(); + final pick = context.watch(); // 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 // (clearing it synchronously would call notifyListeners mid-build). - final requestedTab = provider.requestedTab; + final requestedTab = settings.requestedTab; if (requestedTab != null) { _currentTab = requestedTab; WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.consumeRequestedTab(), + (_) => settings.consumeRequestedTab(), ); } - final pickRequest = provider.pickRequest; + final pickRequest = pick.pickRequest; // While acting as another app's picker, only Files and Photos make // sense as browsable sources - Trash/Shares/Activity/Recent aren't // real "pick a file from here" destinations, and only these two tabs' @@ -242,7 +297,7 @@ class _MainShellViewState extends State { // them, since picking is a separate mode from normal browsing. final visible = pickRequest != null ? [AppTab.files, AppTab.photos] - : provider.visibleTabs; + : settings.visibleTabs; final selectedTab = visible.contains(_currentTab) ? _currentTab : (visible.isNotEmpty ? visible.first : AppTab.files); @@ -260,7 +315,7 @@ class _MainShellViewState extends State { // there's no Navigator route to pop at this, the root screen. canPop: pickRequest == null, onPopInvokedWithResult: (didPop, result) { - if (!didPop && pickRequest != null) provider.cancelPick(); + if (!didPop && pickRequest != null) pick.cancelPick(); }, child: Scaffold( body: Stack( @@ -271,7 +326,7 @@ class _MainShellViewState extends State { .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) .toList(), ), - if (provider.isDownloadingForPick) const _PickingProgressOverlay(), + if (pick.isDownloadingForPick) const _PickingProgressOverlay(), // Each tab now renders its own sticky selection toolbar inline // (right under its sort/filter row) instead of this shell // swapping in a shared floating bar - the bottom nav stays put @@ -279,12 +334,12 @@ class _MainShellViewState extends State { FloatingBottomNavBar( selectedIndex: selectedIndex, items: navItems, - opacity: provider.bottomBarOpacity, - blurSigma: provider.bottomBarBlur, + opacity: settings.bottomBarOpacity, + blurSigma: settings.bottomBarBlur, onDestinationSelected: (index) { final tappedTab = visible[index]; if (tappedTab == _currentTab) { - if (provider.tapTabToScrollTop) { + if (settings.tapTabToScrollTop) { final controller = _scrollControllers[tappedTab]; if (controller != null && controller.hasClients) { controller.animateTo( @@ -315,7 +370,7 @@ class _MainShellViewState extends State { /// Blocks interaction while the selected file(s) download to a local cache /// folder before being handed back to the caller (see -/// `ServerProvider.confirmPick`) - can take a moment for a large file/video. +/// `PickController.confirmPick`) - can take a moment for a large file/video. class _PickingProgressOverlay extends StatelessWidget { const _PickingProgressOverlay(); diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart index c93cd3f..7a8ccb0 100644 --- a/lib/models/app_tab.dart +++ b/lib/models/app_tab.dart @@ -3,7 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; /// Every destination the bottom nav bar can show. Order here is only the /// fallback default — actual order/visibility/default-tab are user -/// configurable and persisted in [ServerProvider]. +/// configurable and persisted in `SettingsController`. enum AppTab { files, photos, favorites, activity, trash, shares, recent } /// At most this many tabs may be visible in the bottom nav bar at once — diff --git a/lib/models/move_copy_result.dart b/lib/models/move_copy_result.dart index 6a34b5c..1ccc12c 100644 --- a/lib/models/move_copy_result.dart +++ b/lib/models/move_copy_result.dart @@ -2,7 +2,7 @@ import 'nextcloud_item.dart'; /// A resolution choice for one conflicting item in a Move/Copy batch - /// keyed by `item.id` in [ConflictChoice] maps passed to -/// `ServerProvider.resolveConflicts`. +/// `ItemOperations.resolveConflicts`. enum ConflictChoice { overwrite, keepBoth, skip } /// One item that couldn't be moved/copied because something with the same @@ -14,7 +14,7 @@ class MoveCopyConflict { } /// Outcome of a Move/Copy batch (an initial attempt via -/// `ServerProvider.moveItems`/`copyItems`, or a follow-up +/// `ItemOperations.moveItems`/`copyItems`, or a follow-up /// `resolveConflicts` call). [blockedReason] is set instead of attempting /// anything at all when the destination itself is invalid (e.g. moving a /// folder into its own subfolder). diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart index 3283c58..aab8f3b 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -34,5 +34,5 @@ enum SyncItemStatus { none, syncing, synced, conflict } /// The persistent header chip/panel's overall status (account-wide, not /// scoped to whatever folder is currently browsed) - see -/// `ServerProvider.syncHeaderStatus`. +/// `SyncStatusController.syncHeaderStatus`. enum SyncHeaderStatus { off, syncing, done, alert } diff --git a/lib/providers/favorites_controller.dart b/lib/providers/favorites_controller.dart new file mode 100644 index 0000000..a158603 --- /dev/null +++ b/lib/providers/favorites_controller.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import '../models/nextcloud_item.dart'; +import 'files_controller.dart'; +import 'session_controller.dart'; + +/// The Favorites tab: every favorited item across the whole account (not +/// scoped to any one folder - a favorited item several folders deep still +/// shows up regardless of whether its parent folders are themselves +/// favorited, which is exactly why this is its own account-wide fetch +/// rather than a filter toggle over whatever folder Files happens to be +/// browsing - see `server.md`'s Favorites section for the full history of +/// why a filter-toggle approach was tried and reverted). Depends on +/// [FilesController] to reuse its exact +/// display prefs (hidden/type-filter/sort/storage-scope) via +/// [FilesController.applyFilesDisplayPrefs] - deliberately shared rather +/// than a second parallel settings dimension. +class FavoritesController extends ChangeNotifier { + final SessionController session; + final FilesController files; + + List _allFavorites = []; + bool _isLoading = false; + String? _errorMessage; + // True once fetchAll has run at least once (i.e. the Favorites tab has + // been visited) - delete/rename/move/copy re-sync afterward, but only + // when it's actually been loaded, so those actions don't pay for an + // extra network round-trip on every Files/Photos edit for an account + // that's never opened the Favorites tab this session. + bool _everFetched = false; + + FavoritesController(this.session, this.files) { + session.addAccountClearedListener(_onAccountCleared); + } + + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + + /// The Favorites tab's content, run through the exact same hidden/type- + /// filter/sort/storage-scope display prefs as the Files tab. No + /// favorites filter needed here - the source list is already + /// all-favorites. + List get items => files.applyFilesDisplayPrefs(_allFavorites); + + void _onAccountCleared() { + _allFavorites = []; + _isLoading = false; + _errorMessage = null; + _everFetched = false; + notifyListeners(); + } + + /// Loads every favorited item across the whole account for the Favorites + /// tab. + Future fetchAll() async { + final service = session.service; + if (!session.isLoggedIn || service == null) return; + final gen = session.sessionGeneration; + _everFetched = true; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final favorites = await service.fetchFavorites(); + if (gen != session.sessionGeneration) return; + _allFavorites = favorites; + debugPrint('[FavoritesController] Loaded ${_allFavorites.length} favorites'); + } catch (e) { + if (gen != session.sessionGeneration) return; + debugPrint('[FavoritesController] Error fetching favorites: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + if (gen == session.sessionGeneration) { + _isLoading = false; + notifyListeners(); + } + } + } + + /// Re-syncs [_allFavorites] after a delete/rename/move/copy elsewhere in + /// the app - those operations don't know how to patch this list in place + /// (a move changes an item's path; WebDAV COPY's handling of custom + /// properties like `oc:favorite` isn't reliable enough to assume the + /// copy is still favorited), so this just refetches - but only if + /// Favorites has actually been loaded this session. Called by + /// `ItemOperations` after delete/rename/move/copy. + Future syncIfLoaded() { + return _everFetched ? fetchAll() : Future.value(); + } + + /// Patches a favorite-toggle result into [_allFavorites] in place (added + /// if newly favorited, removed if un-favorited, updated otherwise) - + /// called by `ItemOperations.toggleItemFavorite` after a successful + /// server call. Cheap enough not to need a full refetch, unlike + /// [syncIfLoaded]. + void applyFavoriteToggle(NextcloudItem updated) { + final index = _allFavorites.indexWhere((i) => i.id == updated.id); + if (updated.isFavorite) { + if (index != -1) { + _allFavorites[index] = updated; + } else { + _allFavorites = [..._allFavorites, updated]; + } + } else if (index != -1) { + _allFavorites = [..._allFavorites]..removeAt(index); + } + notifyListeners(); + } +} diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart new file mode 100644 index 0000000..5502119 --- /dev/null +++ b/lib/providers/files_controller.dart @@ -0,0 +1,557 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/nextcloud_item.dart'; +import 'session_controller.dart'; + +enum FileSortField { name, dateCreated, dateModified, size } + +/// Which storage a listing shows — always exactly one, like the list/grid +/// view toggle, not an optional filter. Shared across Files/Photos/ +/// Favorites (see [applyCommonFilters]'s doc comment). +enum StorageScope { cloud, external } + +/// The Files tab's files/folders/both filter — independent of and applied +/// after [StorageScope]/favorites/hidden filtering. +enum FilesTypeFilter { all, filesOnly, foldersOnly } + +/// How aggressively the Files tab's folder listings (not thumbnails/file +/// content - just the list of names/sizes/dates) are reused across +/// navigation instead of refetched from the server every time. +enum CachePolicy { + /// Every navigation refetches - today's behavior. + never, + + /// A listing is reused until it's older than [FilesController. + /// cacheIntervalMinutes]; a timer also proactively refreshes the current + /// folder on that same interval while the app is in the foreground. + interval, + + /// A listing is reused indefinitely until the user pulls to refresh. + manual, +} + +/// One cached folder listing and when it was fetched. +class _CachedDirectory { + final List items; + final DateTime fetchedAt; + + const _CachedDirectory(this.items, this.fetchedAt); +} + +/// The Files tab: current folder/browsing state, display prefs (grid/list, +/// hidden, type filter, storage scope, sort), the directory-listing cache, +/// and - piggybacked onto the same fetch cycle as the original single +/// provider did - account quota and the Activity tab's feed (neither is +/// really "Files" data, but both were always fetched alongside the current +/// folder's listing, not independently; preserved here as-is rather than +/// invented a home for them, since untangling that wasn't asked for). Also +/// the shared [storageScope]/[applyCommonFilters]/[applyFilesDisplayPrefs] +/// logic Photos/Favorites both reuse - see their own controllers' doc +/// comments for why they depend on this one. +/// +/// Split out of the former single `ServerProvider` god object. +class FilesController extends ChangeNotifier with WidgetsBindingObserver { + final SessionController session; + + static const _prefGridView = 'ui_grid_view'; + static const _prefStorageScope = 'ui_storage_scope'; + static const _prefFilesTypeFilter = 'ui_files_type_filter'; + static const _prefShowHiddenFiles = 'ui_show_hidden'; + static const _prefFolderSort = 'ui_folder_sort'; + static const _prefCachePolicy = 'ui_cache_policy'; + static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; + + String _currentFolderPath = '/'; + List _pathStack = ['/']; + + bool _isGridView = false; + StorageScope _storageScope = StorageScope.cloud; + FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all; + bool _showHiddenFiles = false; + + final Map _folderSortField = {}; + final Map _folderSortAscending = {}; + + CachePolicy _cachePolicy = CachePolicy.never; + int _cacheIntervalMinutes = 5; + final Map _directoryCache = {}; + Timer? _cacheRefreshTimer; + + List _items = []; + NextcloudUserQuota? _quota; + List _activities = []; + bool _isLoading = false; + String? _errorMessage; + + FilesController(this.session) { + WidgetsBinding.instance.addObserver(this); + session.addAccountClearedListener(_onAccountCleared); + session.addAccountActivatedListener(_onAccountActivated); + } + + // Getters + String get currentFolderPath => _currentFolderPath; + List get pathStack => _pathStack; + bool get isGridView => _isGridView; + StorageScope get storageScope => _storageScope; + FilesTypeFilter get filesTypeFilter => _filesTypeFilter; + bool get showHiddenFiles => _showHiddenFiles; + FileSortField get filesSortField => + _folderSortField[_currentFolderPath] ?? FileSortField.name; + bool get filesSortAscending => + _folderSortAscending[_currentFolderPath] ?? true; + CachePolicy get cachePolicy => _cachePolicy; + int get cacheIntervalMinutes => _cacheIntervalMinutes; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + NextcloudUserQuota? get quota => _quota; + List get activities => _activities; + + List get items => applyFilesDisplayPrefs(_items); + + int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) { + switch (field) { + case FileSortField.name: + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case FileSortField.dateCreated: + return a.dateCreated.compareTo(b.dateCreated); + case FileSortField.dateModified: + return a.lastModified.compareTo(b.lastModified); + case FileSortField.size: + return a.size.compareTo(b.size); + } + } + + /// True if [item] or any ancestor folder in its path is a dotfile/dotfolder. + bool _isHiddenItem(NextcloudItem item) { + return item.path + .split('/') + .where((segment) => segment.isNotEmpty) + .any((segment) => segment.startsWith('.')); + } + + /// Applies the shared favorites-only/hidden-files/storage-scope toggles - + /// shared by Files' own [items], Photos' `photoItems` (its own favorites- + /// only/hidden toggles, but [storageScope] here), and Favorites' + /// `favoriteItems` (via [applyFilesDisplayPrefs]). + List applyCommonFilters( + List source, { + required bool showFavoritesOnly, + required bool showHidden, + }) { + var filtered = source; + if (showFavoritesOnly) { + filtered = filtered.where((item) => item.isFavorite).toList(); + } + if (!showHidden) { + filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); + } + filtered = filtered + .where( + (item) => _storageScope == StorageScope.external + ? item.isExternalStorage + : !item.isExternalStorage, + ) + .toList(); + return filtered; + } + + /// Applies the Files tab's current sort/filter display prefs (hidden, + /// type filter, storage scope, sort field/direction) to an arbitrary raw + /// item list - factored out of the [items] getter so both the Favorites + /// tab and the Move/Copy destination picker (which fetches its own + /// listings via [fetchFolderListing] rather than reading [items] itself) + /// can render with the exact same controls/behavior as the Files tab + /// without duplicating this logic. + List applyFilesDisplayPrefs(List rawItems) { + var filtered = applyCommonFilters( + rawItems, + // Files itself doesn't filter by favorite - that's the dedicated + // Favorites tab's job. + showFavoritesOnly: false, + showHidden: _showHiddenFiles, + ); + switch (_filesTypeFilter) { + case FilesTypeFilter.all: + break; + case FilesTypeFilter.filesOnly: + filtered = filtered.where((i) => !i.isFolder).toList(); + case FilesTypeFilter.foldersOnly: + filtered = filtered.where((i) => i.isFolder).toList(); + } + + final field = filesSortField; + final folders = filtered.where((i) => i.isFolder).toList() + ..sort((a, b) => _compareItems(a, b, field)); + final files = filtered.where((i) => !i.isFolder).toList() + ..sort((a, b) => _compareItems(a, b, field)); + return filesSortAscending + ? [...folders, ...files] + : [...folders.reversed, ...files.reversed]; + } + + void _onAccountCleared() { + _cacheRefreshTimer?.cancel(); + _directoryCache.clear(); + _folderSortField.clear(); + _folderSortAscending.clear(); + _items = []; + _quota = null; + _activities = []; + _currentFolderPath = '/'; + _pathStack = ['/']; + _isGridView = false; + _storageScope = StorageScope.cloud; + _filesTypeFilter = FilesTypeFilter.all; + _showHiddenFiles = false; + _cachePolicy = CachePolicy.never; + _cacheIntervalMinutes = 5; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } + + Future _onAccountActivated() async { + final id = session.activeAccountId; + if (id != null) { + final prefs = await session.prefsFuture; + String k(String base) => session.accountStore.accountPrefKey(id, base); + + _isGridView = prefs.getBool(k(_prefGridView)) ?? false; + final storageScopeName = prefs.getString(k(_prefStorageScope)); + _storageScope = StorageScope.values.firstWhere( + (s) => s.name == storageScopeName, + orElse: () => StorageScope.cloud, + ); + final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter)); + _filesTypeFilter = FilesTypeFilter.values.firstWhere( + (f) => f.name == filesTypeFilterName, + orElse: () => FilesTypeFilter.all, + ); + _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; + + final folderSortJson = prefs.getString(k(_prefFolderSort)); + if (folderSortJson != null) { + try { + final decoded = jsonDecode(folderSortJson) as Map; + for (final entry in decoded.entries) { + final value = entry.value as Map; + final fieldName = value['field'] as String?; + if (fieldName != null) { + _folderSortField[entry.key] = FileSortField.values.firstWhere( + (f) => f.name == fieldName, + orElse: () => FileSortField.name, + ); + } + final ascending = value['ascending'] as bool?; + if (ascending != null) _folderSortAscending[entry.key] = ascending; + } + } catch (e) { + debugPrint('[FilesController] Folder sort restore failed: $e'); + } + } + + final cachePolicyName = prefs.getString(k(_prefCachePolicy)); + _cachePolicy = CachePolicy.values.firstWhere( + (c) => c.name == cachePolicyName, + orElse: () => CachePolicy.never, + ); + _cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5; + notifyListeners(); + } + _startCacheRefreshTimerIfNeeded(); + await refreshData(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Only relevant under CachePolicy.interval. + if (state == AppLifecycleState.resumed) { + _startCacheRefreshTimerIfNeeded(); + } else if (state == AppLifecycleState.paused) { + _cacheRefreshTimer?.cancel(); + } + } + + void _startCacheRefreshTimerIfNeeded() { + _cacheRefreshTimer?.cancel(); + if (_cachePolicy != CachePolicy.interval || !session.isLoggedIn) return; + _cacheRefreshTimer = Timer.periodic( + Duration(minutes: _cacheIntervalMinutes), + (_) => refreshData(), + ); + } + + bool _isCacheFresh(String path) { + if (_cachePolicy == CachePolicy.never) return false; + final cached = _directoryCache[path]; + if (cached == null) return false; + if (_cachePolicy == CachePolicy.manual) return true; + return DateTime.now().difference(cached.fetchedAt) < + Duration(minutes: _cacheIntervalMinutes); + } + + Future refreshData() async { + final service = session.service; + if (!session.isLoggedIn || service == null) { + debugPrint( + '[FilesController] refreshData skipped: isLoggedIn=${session.isLoggedIn}, service=${service != null}', + ); + return; + } + // Captured so a response landing after an account switch mid-flight + // can recognize it's for an account the user has already left and + // discard itself instead of overwriting the new account's content. + final gen = session.sessionGeneration; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + debugPrint('[FilesController] Refreshing data for path: $_currentFolderPath'); + + try { + final items = await service.fetchDirectory(_currentFolderPath); + if (gen != session.sessionGeneration) return; + _items = items; + _directoryCache[_currentFolderPath] = _CachedDirectory( + _items, + DateTime.now(), + ); + debugPrint( + '[FilesController] Loaded ${_items.length} items for $_currentFolderPath', + ); + try { + _quota = await service.fetchUserQuota(); + } catch (e) { + debugPrint('[FilesController] Quota fetch warning: $e'); + } + try { + _activities = await service.fetchActivities(); + } catch (e) { + debugPrint('[FilesController] Activity fetch warning: $e'); + } + } catch (e) { + if (gen != session.sessionGeneration) return; + debugPrint('[FilesController] Error fetching directory: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + if (gen == session.sessionGeneration) { + _isLoading = false; + notifyListeners(); + } + } + } + + Future> searchFiles(String query) async { + final service = session.service; + if (service == null) return []; + return service.searchFiles(query); + } + + Future navigateToFolder(String path) async { + _pathStack.add(path); + await _navigateTo(path); + } + + Future navigateUp() async { + if (_pathStack.length > 1) { + _pathStack.removeLast(); + await _navigateTo(_pathStack.last); + } + } + + /// Jumps directly to an ancestor folder by its position in [pathStack] + /// (as tapped from a breadcrumb), trimming everything below it. + Future navigateToPathIndex(int index) async { + if (index < 0 || index >= _pathStack.length - 1) return; + _pathStack = _pathStack.sublist(0, index + 1); + await _navigateTo(_pathStack.last); + } + + /// Navigates directly to an arbitrary absolute folder path (e.g. from a + /// search result) - unlike [navigateToFolder], which assumes [path] is a + /// child of wherever the user is currently browsing and just appends it, + /// this rebuilds the whole breadcrumb trail from root so it's correct + /// regardless of where the user was before. + Future navigateToAbsoluteFolder(String path) async { + final normalized = path.trim().replaceAll(RegExp(r'/+$'), ''); + final segments = normalized.split('/').where((s) => s.isNotEmpty).toList(); + final stack = ['/']; + var current = ''; + for (final segment in segments) { + current = '$current/$segment'; + stack.add(current); + } + _pathStack = stack; + await _navigateTo(stack.last); + } + + /// Switches the current folder to [path], serving its listing straight + /// from the cache when that's still fresh (instant, no network call at + /// all) and only falling back to [refreshData] otherwise. + Future _navigateTo(String path) async { + _currentFolderPath = path; + final cached = _directoryCache[path]; + if (cached != null && _isCacheFresh(path)) { + debugPrint('[FilesController] Serving cached listing for $path'); + _items = cached.items; + _errorMessage = null; + notifyListeners(); + return; + } + await refreshData(); + } + + /// A destination-picker-only fetch: the current folder listing state + /// (`items`/`currentFolderPath`/`pathStack`/the directory cache) belongs + /// to whichever tab is actively browsing (Files), so the Move/Copy + /// destination picker deliberately doesn't touch any of it - it fetches + /// listings for its own local navigation state through this instead. + Future> fetchFolderListing(String path) { + return session.service?.fetchDirectory(path) ?? Future.value([]); + } + + void invalidateCache() => _directoryCache.clear(); + + /// Persists a per-account browsing pref under its `acct__`-namespaced + /// key. No-op if there's no active account (shouldn't normally happen - + /// these setters are only reachable from screens that require one). + void _persistAccountPref( + String baseKey, + void Function(SharedPreferences prefs, String key) write, + ) { + final id = session.activeAccountId; + if (id == null) return; + session.prefsFuture.then( + (p) => write(p, session.accountStore.accountPrefKey(id, baseKey)), + ); + } + + void setGridView(bool value) { + if (_isGridView == value) return; + _isGridView = value; + notifyListeners(); + _persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value)); + } + + void setStorageScope(StorageScope scope) { + if (_storageScope == scope) return; + _storageScope = scope; + notifyListeners(); + _persistAccountPref( + _prefStorageScope, + (p, key) => p.setString(key, scope.name), + ); + } + + void setFilesTypeFilter(FilesTypeFilter filter) { + if (_filesTypeFilter == filter) return; + _filesTypeFilter = filter; + notifyListeners(); + _persistAccountPref( + _prefFilesTypeFilter, + (p, key) => p.setString(key, filter.name), + ); + } + + /// Only *enabling* hidden-files visibility is gated - hiding them again + /// never exposes anything, so that direction is always allowed instantly. + Future toggleShowHiddenFiles() async { + if (!_showHiddenFiles) { + if (!await session.passGate( + session.lockHiddenFiles, + 'Unlock to show hidden files', + )) { + return; + } + } + _showHiddenFiles = !_showHiddenFiles; + notifyListeners(); + _persistAccountPref( + _prefShowHiddenFiles, + (p, key) => p.setBool(key, _showHiddenFiles), + ); + } + + void _persistFolderSort() { + final combined = {}; + for (final path in { + ..._folderSortField.keys, + ..._folderSortAscending.keys, + }) { + combined[path] = { + 'field': _folderSortField[path]?.name, + 'ascending': _folderSortAscending[path], + }; + } + _persistAccountPref( + _prefFolderSort, + (p, key) => p.setString(key, jsonEncode(combined)), + ); + } + + void setFilesSortField(FileSortField field) { + if (filesSortField == field) return; + _folderSortField[_currentFolderPath] = field; + notifyListeners(); + _persistFolderSort(); + } + + void toggleFilesSortOrder() { + _folderSortAscending[_currentFolderPath] = !filesSortAscending; + notifyListeners(); + _persistFolderSort(); + } + + void setCachePolicy(CachePolicy policy) { + if (_cachePolicy == policy) return; + _cachePolicy = policy; + notifyListeners(); + _persistAccountPref( + _prefCachePolicy, + (p, key) => p.setString(key, policy.name), + ); + _startCacheRefreshTimerIfNeeded(); + } + + void setCacheIntervalMinutes(int minutes) { + final clamped = minutes.clamp(1, 60); + if (_cacheIntervalMinutes == clamped) return; + _cacheIntervalMinutes = clamped; + notifyListeners(); + _persistAccountPref( + _prefCacheIntervalMinutes, + (p, key) => p.setInt(key, clamped), + ); + _startCacheRefreshTimerIfNeeded(); + } + + Future createFolder(String folderName) async { + final service = session.service; + if (service == null) return false; + final success = await service.createFolder(_currentFolderPath, folderName); + if (success) { + await refreshData(); + } + return success; + } + + /// Patches a favorite-toggle result into [items] in place (no refetch) - + /// called by `ItemOperations.toggleItemFavorite` after a successful + /// server call. + void applyFavoriteToggle(NextcloudItem updated) { + final index = _items.indexWhere((i) => i.id == updated.id); + if (index != -1) _items[index] = updated; + notifyListeners(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _cacheRefreshTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/item_operations.dart b/lib/providers/item_operations.dart new file mode 100644 index 0000000..044f49c --- /dev/null +++ b/lib/providers/item_operations.dart @@ -0,0 +1,358 @@ +import 'package:flutter/foundation.dart'; +import '../models/move_copy_result.dart'; +import '../models/nextcloud_file_version.dart'; +import '../models/nextcloud_item.dart'; +import '../models/nextcloud_share.dart'; +import '../models/nextcloud_sharee.dart'; +import 'favorites_controller.dart'; +import 'files_controller.dart'; +import 'photos_controller.dart'; +import 'session_controller.dart'; + +/// The one place that knows how an item mutation (delete/rename/move/copy/ +/// favorite-toggle/create-folder) ripples across Files/Photos/Favorites - +/// those three each own their own item list, but a mutation triggered from +/// *any* of them (see each call site in the views) needs to reconcile all +/// three, since e.g. a delete removes an item Photos might also be +/// showing. Deliberately holds direct references to the sibling +/// controllers (constructed once in `main.dart`, after they already exist) +/// rather than a generic event bus, so callers can still `await` a +/// mutation and know every affected list has already been reconciled by +/// the time it returns - exactly the same guarantee the single +/// `ServerProvider` god object gave for free before this was split out. +/// +/// Also home to the file-details sheet's per-item pass-throughs (shares/ +/// activity/versions) - stateless, no stored data, so they don't need a +/// controller of their own. +class ItemOperations { + final SessionController session; + final FilesController files; + final PhotosController photos; + final FavoritesController favorites; + + const ItemOperations({ + required this.session, + required this.files, + required this.photos, + required this.favorites, + }); + + Future deleteItem(String itemPath) async { + final service = session.service; + if (service == null) return false; + final success = await service.deleteItem(itemPath); + if (success) { + photos.applyDelete(itemPath); + await files.refreshData(); + await favorites.syncIfLoaded(); + } + return success; + } + + Future renameItem(NextcloudItem item, String newName) async { + final service = session.service; + if (service == null) return false; + final success = await service.renameItem(item.path, newName); + if (success) { + await files.refreshData(); + await favorites.syncIfLoaded(); + } + return success; + } + + /// True if [destFolderPath] is [folderPath] itself or one of its own + /// descendants - moving/copying a folder into itself (or a subfolder of + /// itself) is nonsensical and rejected client-side rather than left to + /// the server to (maybe) reject. + bool _isSelfOrDescendant(String destFolderPath, String folderPath) { + final dest = destFolderPath.endsWith('/') + ? destFolderPath + : '$destFolderPath/'; + final folder = folderPath.endsWith('/') ? folderPath : '$folderPath/'; + return dest == folder || dest.startsWith(folder); + } + + Future _moveOrCopyItems( + List items, + String destFolderPath, { + required bool copy, + }) async { + final service = session.service; + if (service == null || items.isEmpty) return const MoveCopyResult(); + for (final item in items) { + if (item.isFolder && _isSelfOrDescendant(destFolderPath, item.path)) { + return const MoveCopyResult( + blockedReason: + "Can't move a folder into itself or one of its own subfolders", + ); + } + } + + var succeeded = 0; + var failed = 0; + final conflicts = []; + for (final item in items) { + final status = copy + ? await service.copyItem(item.path, destFolderPath) + : await service.moveItem(item.path, destFolderPath); + if (status == 201 || status == 204) { + succeeded++; + } else if (status == 412) { + conflicts.add(MoveCopyConflict(item)); + } else { + failed++; + } + } + if (succeeded > 0) { + files.invalidateCache(); + await files.refreshData(); + await favorites.syncIfLoaded(); + } + return MoveCopyResult( + succeeded: succeeded, + conflicts: conflicts, + failed: failed, + ); + } + + /// Moves every item in [items] into [destFolderPath], keeping each + /// item's own filename. Items that would collide with something already + /// there come back in [MoveCopyResult.conflicts] rather than failing + /// outright - resolve those with [resolveConflicts]. + Future moveItems( + List items, + String destFolderPath, + ) { + return _moveOrCopyItems(items, destFolderPath, copy: false); + } + + /// Same as [moveItems] but duplicates rather than relocates. + Future copyItems( + List items, + String destFolderPath, + ) { + return _moveOrCopyItems(items, destFolderPath, copy: true); + } + + /// Finds the first `name (n).ext` not already in [existing] - `existing` + /// is mutated as names are claimed, so a batch of "keep both" resolutions + /// never picks the same generated name twice. + String _nextAvailableName(String name, Set existing) { + if (!existing.contains(name)) return name; + final dotIndex = name.lastIndexOf('.'); + final hasExtension = dotIndex > 0 && dotIndex < name.length - 1; + final base = hasExtension ? name.substring(0, dotIndex) : name; + final ext = hasExtension ? name.substring(dotIndex) : ''; + var n = 2; + while (existing.contains('$base ($n)$ext')) { + n++; + } + return '$base ($n)$ext'; + } + + /// Resolves a previous [moveItems]/[copyItems] call's conflicts per + /// [choices] (keyed by `item.id`): overwrite the existing item, keep + /// both (renamed against a fresh listing of [destFolderPath] so two + /// "keep both" picks in the same batch can't collide with each other), + /// or skip (left untouched at the source). + Future resolveConflicts( + List conflicts, + String destFolderPath, { + required bool copy, + required Map choices, + }) async { + final service = session.service; + if (service == null || conflicts.isEmpty) return const MoveCopyResult(); + + final needsKeepBoth = conflicts.any( + (c) => choices[c.item.id] == ConflictChoice.keepBoth, + ); + final existingNames = needsKeepBoth + ? (await files.fetchFolderListing(destFolderPath)) + .map((i) => i.name) + .toSet() + : {}; + + var succeeded = 0; + var failed = 0; + for (final conflict in conflicts) { + final choice = choices[conflict.item.id] ?? ConflictChoice.skip; + if (choice == ConflictChoice.skip) continue; + + final int status; + if (choice == ConflictChoice.overwrite) { + status = copy + ? await service.copyItem( + conflict.item.path, + destFolderPath, + overwrite: true, + ) + : await service.moveItem( + conflict.item.path, + destFolderPath, + overwrite: true, + ); + } else { + final newName = _nextAvailableName(conflict.item.name, existingNames); + existingNames.add(newName); + status = copy + ? await service.copyItem( + conflict.item.path, + destFolderPath, + newName: newName, + ) + : await service.moveItem( + conflict.item.path, + destFolderPath, + newName: newName, + ); + } + if (status == 201 || status == 204) { + succeeded++; + } else { + failed++; + } + } + if (succeeded > 0) { + files.invalidateCache(); + await files.refreshData(); + await favorites.syncIfLoaded(); + } + return MoveCopyResult(succeeded: succeeded, failed: failed); + } + + Future toggleItemFavorite(NextcloudItem item) async { + final service = session.service; + if (service == null) return; + final success = await service.toggleFavorite(item.path, item.isFavorite); + if (success) { + final updated = item.copyWith(isFavorite: !item.isFavorite); + files.applyFavoriteToggle(updated); + photos.applyFavoriteToggle(updated); + favorites.applyFavoriteToggle(updated); + } + } + + /// Creates a public link share for [item] and returns its URL, or null on + /// failure (server error, unsupported item, etc). + Future createShareLink(NextcloudItem item) async { + final service = session.service; + if (service == null) return null; + try { + return await service.createPublicShareLink(item.path); + } catch (e) { + debugPrint('[ItemOperations] createShareLink failed: $e'); + return null; + } + } + + // --- Details sheet: per-item sharing/activity/versions --- + // + // Thin pass-throughs (no stored state) - the data here is transient and + // scoped to whichever item's Details sheet happens to be open, so it + // lives in that sheet's own local widget state rather than a controller. + + Future> fetchItemShares(NextcloudItem item) async { + final service = session.service; + if (service == null) return []; + try { + return await service.fetchSharesForPath(item.path); + } catch (e) { + debugPrint('[ItemOperations] fetchItemShares failed: $e'); + return []; + } + } + + Future> fetchInheritedShares(NextcloudItem item) async { + final service = session.service; + if (service == null) return []; + return service.fetchInheritedShares(item.path); + } + + Future> searchSharees(String query) async { + final service = session.service; + if (service == null) return []; + try { + return await service.searchSharees(query); + } catch (e) { + debugPrint('[ItemOperations] searchSharees failed: $e'); + return []; + } + } + + Future createShare({ + required String path, + required int shareType, + String? shareWith, + String? password, + DateTime? expireDate, + }) async { + final service = session.service; + if (service == null) return null; + try { + return await service.createShare( + path: path, + shareType: shareType, + shareWith: shareWith, + password: password, + expireDate: expireDate, + ); + } catch (e) { + debugPrint('[ItemOperations] createShare failed: $e'); + return null; + } + } + + /// Deletes a share (from the file-details/Share sheet, not the Shares + /// tab itself - see `SharesController.deleteShare` for that one, which + /// additionally patches the tab's own list). + Future deleteShare(NextcloudShare share) async { + final service = session.service; + if (service == null) return false; + return service.deleteShare(share.id); + } + + Future> fetchFileActivity(NextcloudItem item) async { + final service = session.service; + if (service == null) return []; + return service.fetchFileActivity(item.id); + } + + Future> fetchFileVersions( + NextcloudItem item, + ) async { + final service = session.service; + if (service == null) return []; + return service.fetchFileVersions(item.id); + } + + Future restoreFileVersion( + NextcloudItem item, + String versionLabel, + ) async { + final service = session.service; + if (service == null) return false; + try { + return await service.restoreFileVersion(item.id, versionLabel); + } catch (e) { + debugPrint('[ItemOperations] restoreFileVersion failed: $e'); + return false; + } + } + + Future downloadVersion( + NextcloudItem item, + String versionLabel, + String savePath, + ) async { + final service = session.service; + if (service == null) return false; + try { + await service.downloadVersionToFile(item.id, versionLabel, savePath); + return true; + } catch (e) { + debugPrint('[ItemOperations] downloadVersion failed: $e'); + return false; + } + } +} diff --git a/lib/providers/photos_controller.dart b/lib/providers/photos_controller.dart new file mode 100644 index 0000000..94f645b --- /dev/null +++ b/lib/providers/photos_controller.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/nextcloud_item.dart'; +import 'files_controller.dart'; +import 'session_controller.dart'; + +/// The Photos tab: every image/video across the whole account (not just +/// the currently browsed folder - see [fetchAllMedia]). Depends on +/// [FilesController] only for the shared [StorageScope] toggle and +/// [FilesController.applyCommonFilters] - Photos' own favorites-only/ +/// hidden toggles are independent of Files', but the storage-scope split +/// (cloud vs external) is one shared setting across every tab. +class PhotosController extends ChangeNotifier { + final SessionController session; + final FilesController files; + + static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; + static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; + static const _prefSortField = 'ui_sort_field'; + static const _prefSortAscending = 'ui_sort_ascending'; + + List _allMedia = []; + bool _isLoading = false; + String? _errorMessage; + + bool _showFavoritesOnly = false; + bool _showHidden = false; + FileSortField _sortField = FileSortField.name; + bool _sortAscending = true; + + PhotosController(this.session, this.files) { + session.addAccountClearedListener(_onAccountCleared); + session.addAccountActivatedListener(_onAccountActivated); + } + + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + bool get showFavoritesOnly => _showFavoritesOnly; + bool get showHidden => _showHidden; + FileSortField get sortField => _sortField; + bool get sortAscending => _sortAscending; + + /// All images/videos across the whole account (not just the currently + /// browsed folder) — see [fetchAllMedia]. + List get items { + final media = _allMedia.where((i) => i.isMedia).toList(); + final filtered = files.applyCommonFilters( + media, + showFavoritesOnly: _showFavoritesOnly, + showHidden: _showHidden, + )..sort((a, b) => _compare(a, b)); + return _sortAscending ? filtered : filtered.reversed.toList(); + } + + int _compare(NextcloudItem a, NextcloudItem b) { + switch (_sortField) { + case FileSortField.name: + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case FileSortField.dateCreated: + return a.dateCreated.compareTo(b.dateCreated); + case FileSortField.dateModified: + return a.lastModified.compareTo(b.lastModified); + case FileSortField.size: + return a.size.compareTo(b.size); + } + } + + void _onAccountCleared() { + _allMedia = []; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } + + Future _onAccountActivated() async { + final id = session.activeAccountId; + if (id != null) { + final prefs = await session.prefsFuture; + String k(String base) => session.accountStore.accountPrefKey(id, base); + _showFavoritesOnly = + prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false; + _showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; + final sortFieldName = prefs.getString(k(_prefSortField)); + _sortField = FileSortField.values.firstWhere( + (f) => f.name == sortFieldName, + orElse: () => FileSortField.name, + ); + _sortAscending = prefs.getBool(k(_prefSortAscending)) ?? true; + notifyListeners(); + } + unawaited(fetchAllMedia()); + } + + /// Loads every image/video across the whole account for the Photos tab. + Future fetchAllMedia() async { + final service = session.service; + if (!session.isLoggedIn || service == null) return; + final gen = session.sessionGeneration; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final media = await service.fetchAllMedia(); + if (gen != session.sessionGeneration) return; + _allMedia = media; + debugPrint('[PhotosController] Loaded ${_allMedia.length} media items'); + } catch (e) { + if (gen != session.sessionGeneration) return; + debugPrint('[PhotosController] Error fetching all media: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + if (gen == session.sessionGeneration) { + _isLoading = false; + notifyListeners(); + } + } + } + + void _persistAccountPref( + String baseKey, + void Function(SharedPreferences prefs, String key) write, + ) { + final id = session.activeAccountId; + if (id == null) return; + session.prefsFuture.then( + (p) => write(p, session.accountStore.accountPrefKey(id, baseKey)), + ); + } + + void toggleFavoritesFilter() { + _showFavoritesOnly = !_showFavoritesOnly; + notifyListeners(); + _persistAccountPref( + _prefShowFavoritesOnlyPhotos, + (p, key) => p.setBool(key, _showFavoritesOnly), + ); + } + + Future toggleShowHidden() async { + if (!_showHidden) { + if (!await session.passGate( + session.lockHiddenFiles, + 'Unlock to show hidden files', + )) { + return; + } + } + _showHidden = !_showHidden; + notifyListeners(); + _persistAccountPref( + _prefShowHiddenPhotos, + (p, key) => p.setBool(key, _showHidden), + ); + } + + void setSortField(FileSortField field) { + if (_sortField == field) return; + _sortField = field; + notifyListeners(); + _persistAccountPref(_prefSortField, (p, key) => p.setString(key, field.name)); + } + + void toggleSortOrder() { + _sortAscending = !_sortAscending; + notifyListeners(); + _persistAccountPref( + _prefSortAscending, + (p, key) => p.setBool(key, _sortAscending), + ); + } + + /// Removes an item by path (a delete elsewhere in the app) - called by + /// `ItemOperations.deleteItem` after a successful server call. + void applyDelete(String path) { + _allMedia = _allMedia.where((i) => i.path != path).toList(); + notifyListeners(); + } + + /// Patches a favorite-toggle result into [items]' source list in place - + /// called by `ItemOperations.toggleItemFavorite` after a successful + /// server call. + void applyFavoriteToggle(NextcloudItem updated) { + final index = _allMedia.indexWhere((i) => i.id == updated.id); + if (index != -1) _allMedia[index] = updated; + notifyListeners(); + } +} diff --git a/lib/providers/pick_controller.dart b/lib/providers/pick_controller.dart new file mode 100644 index 0000000..ead4278 --- /dev/null +++ b/lib/providers/pick_controller.dart @@ -0,0 +1,95 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import '../models/nextcloud_item.dart'; +import '../models/pick_request.dart'; +import '../services/pick_intent_service.dart'; +import 'session_controller.dart'; + +/// State for Noo acting as *another* app's GET_CONTENT picker (see +/// `PickIntentService`/`MainActivity.kt`) - non-null [pickRequest] while +/// active, set at startup/onNewPickRequest in `MainShellView`, cleared +/// once the pick is confirmed or cancelled. Cross-cutting (both Files and +/// Photos need to know when picking mode is active and route taps through +/// [confirmPick] instead of their normal open/select behavior), so this is +/// its own small controller rather than living on any one tab's. +class PickController extends ChangeNotifier { + final SessionController session; + + PickController(this.session); + + PickRequest? _pickRequest; + bool _isDownloadingForPick = false; + + PickRequest? get pickRequest => _pickRequest; + bool get isPicking => _pickRequest != null; + bool get isDownloadingForPick => _isDownloadingForPick; + + /// Whether [item] can be handed back to the app that's currently picking + /// - always true for folders (still browsable), mime-filtered for files. + bool itemMatchesPickFilter(NextcloudItem item) { + if (item.isFolder) return true; + final request = _pickRequest; + return request == null || request.matches(item.mimeType); + } + + /// Called once (from MainShellView) as soon as a pick request is known - + /// either the cold-start request or one that arrived via onNewPickRequest + /// while already running. + void setPickRequest(PickRequest request) { + _pickRequest = request; + notifyListeners(); + } + + /// Downloads each selected item to a scratch cache folder, then hands the + /// local paths back to the caller through PickIntentService, which closes + /// the picker Activity on success. Left in [pickRequest] (i.e. picking + /// mode stays visually active) if the download fails partway, so the user + /// can see the error and retry rather than the screen finishing under + /// them with nothing returned to the caller. + Future confirmPick(List items) async { + final service = session.service; + if (_pickRequest == null || service == null || items.isEmpty) { + return false; + } + _isDownloadingForPick = true; + notifyListeners(); + try { + final tempDir = await getTemporaryDirectory(); + final pickDir = Directory(p.join(tempDir.path, 'picker')); + final localPaths = []; + final mimeTypes = []; + for (final item in items) { + // Each item gets its own subfolder (keyed by id, not smashed into + // the filename) so two different items can share a plain file + // name without colliding, while the file on disk - and therefore + // the display name the caller sees via the content:// Uri + // MainActivity.kt hands back - stays exactly `item.name`. + final itemDir = Directory(p.join(pickDir.path, item.id)); + await itemDir.create(recursive: true); + final localPath = p.join(itemDir.path, item.name); + await service.downloadToFile(item.path, localPath); + localPaths.add(localPath); + mimeTypes.add(item.mimeType ?? 'application/octet-stream'); + } + await PickIntentService.finishPick(localPaths, mimeTypes); + _pickRequest = null; + return true; + } catch (_) { + return false; + } finally { + _isDownloadingForPick = false; + notifyListeners(); + } + } + + /// Backs out of picking mode entirely, telling the caller nothing was + /// picked and closing the picker Activity. + Future cancelPick() async { + if (_pickRequest == null) return; + _pickRequest = null; + notifyListeners(); + await PickIntentService.cancelPick(); + } +} diff --git a/lib/providers/recent_controller.dart b/lib/providers/recent_controller.dart new file mode 100644 index 0000000..943d1ed --- /dev/null +++ b/lib/providers/recent_controller.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../models/nextcloud_item.dart'; +import 'session_controller.dart'; + +/// The Recent Files tab. Kept separate from Files' own loading/error +/// state, likewise. +class RecentController extends ChangeNotifier { + final SessionController session; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + RecentController(this.session) { + session.addAccountClearedListener(_onAccountCleared); + session.addAccountActivatedListener(_onAccountActivated); + } + + List get items => _items; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + + void _onAccountCleared() { + _items = []; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } + + void _onAccountActivated() { + unawaited(fetchAll()); + } + + Future fetchAll() async { + final service = session.service; + if (!session.isLoggedIn || service == null) return; + final gen = session.sessionGeneration; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final recent = await service.fetchRecentFiles(); + if (gen != session.sessionGeneration) return; + _items = recent; + } catch (e) { + if (gen != session.sessionGeneration) return; + debugPrint('[RecentController] Error fetching recent files: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + if (gen == session.sessionGeneration) { + _isLoading = false; + notifyListeners(); + } + } + } +} diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart deleted file mode 100644 index 573a9ea..0000000 --- a/lib/providers/server_provider.dart +++ /dev/null @@ -1,2339 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import '../models/app_tab.dart'; -import '../models/move_copy_result.dart'; -import '../models/nextcloud_file_version.dart'; -import '../models/nextcloud_item.dart'; -import '../models/nextcloud_share.dart'; -import '../models/nextcloud_sharee.dart'; -import '../models/pick_request.dart'; -import '../models/saved_account.dart'; -import '../models/sync_status.dart'; -import '../services/account_store.dart'; -import '../services/app_lock_service.dart'; -import '../services/login_flow_service.dart'; -import '../services/nextcloud_service.dart'; -import '../services/pick_intent_service.dart'; -import '../services/sync_service.dart'; -import '../theme/app_theme.dart'; - -enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } - -enum FileSortField { name, dateCreated, dateModified, size } - -/// Which storage a listing shows — always exactly one, like the list/grid -/// view toggle, not an optional filter. -enum StorageScope { cloud, external } - -/// What swiping a Files list-view item left/right does, user-configurable -/// in Settings. -enum SwipeAction { none, favorite, delete, share } - -/// The Files tab's files/folders/both filter — independent of and applied -/// after [StorageScope]/favorites/hidden filtering. -enum FilesTypeFilter { all, filesOnly, foldersOnly } - -/// How aggressively the Files tab's folder listings (not thumbnails/file -/// content - just the list of names/sizes/dates) are reused across -/// navigation instead of refetched from the server every time. -enum CachePolicy { - /// Every navigation refetches - today's behavior. - never, - - /// A listing is reused until it's older than [ServerProvider. - /// cacheIntervalMinutes]; a timer also proactively refreshes the current - /// folder on that same interval while the app is in the foreground. - interval, - - /// A listing is reused indefinitely until the user pulls to refresh. - manual, -} - -/// One cached folder listing and when it was fetched. -class _CachedDirectory { - final List items; - final DateTime fetchedAt; - - const _CachedDirectory(this.items, this.fetchedAt); -} - -/// Thumb/track presets for the video player's seek bar, matching the four -/// combinations offered by other Material You media players: a Material 3 -/// slider-style thumb ([classic]), an animated travelling wave with a round -/// thumb ([wavy]), a thin flat bar with no distinct thumb ([slim]), and an -/// animated wave with a tick-mark thumb ([squiggly]). -enum MediaProgressBarStyle { classic, wavy, slim, squiggly } - -class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { - // Cached UI settings/toggles (SharedPreferences keys) - static const _prefThemeMode = 'ui_theme_mode'; - static const _prefUseDynamicColor = 'ui_use_dynamic_color'; - static const _prefSeedColor = 'ui_seed_color'; - static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity'; - static const _prefBottomBarBlur = 'ui_bottom_bar_blur'; - static const _prefGridView = 'ui_grid_view'; - static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; - static const _prefStorageScope = 'ui_storage_scope'; - static const _prefFilesTypeFilter = 'ui_files_type_filter'; - static const _prefShowHiddenFiles = 'ui_show_hidden'; - static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; - static const _prefSortField = 'ui_sort_field'; - static const _prefSortAscending = 'ui_sort_ascending'; - static const _prefFolderSort = 'ui_folder_sort'; - static const _prefTabOrder = 'ui_tab_order'; - static const _prefHiddenTabs = 'ui_hidden_tabs'; - static const _prefDefaultTab = 'ui_default_tab'; - static const _prefSwipeLeftAction = 'ui_swipe_left_action'; - static const _prefSwipeRightAction = 'ui_swipe_right_action'; - static const _prefAmoledDark = 'ui_amoled_dark'; - static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; - static const _prefCachePolicy = 'ui_cache_policy'; - static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; - static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; - static const _prefLoginLockEnabled = 'ui_login_lock_enabled'; - static const _prefLockAccountSwitching = 'ui_lock_account_switching'; - static const _prefLockHiddenFiles = 'ui_lock_hidden_files'; - static const _prefSyncedFolders = 'ui_synced_folders'; - static const _prefSyncEverything = 'ui_sync_everything'; - static const _prefSyncOnCellular = 'ui_sync_on_cellular'; - - final Future _prefsFuture = - SharedPreferences.getInstance(); - - String _serverUrl = ''; - String _username = ''; - String _password = ''; - bool _isLoggedIn = false; - bool _isLoading = false; - bool _isRestoringSession = true; - String? _errorMessage; - - // Login flow v2 state - LoginFlowStatus _loginFlowStatus = LoginFlowStatus.idle; - Uri? _pendingLoginUrl; - Timer? _pollTimer; - Timer? _pollTimeoutTimer; - - // Theme state - Color _seedColor = AppTheme.defaultNextcloudBlue; - ThemeMode _themeMode = ThemeMode.system; - bool _useDynamicColor = true; - bool _amoledDark = false; - MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; - - // UI settings - double _bottomBarOpacity = 0.55; - double _bottomBarBlur = 28; - bool _tapTabToScrollTop = true; - - // Device sync - which remote paths (files or folders, per account) get - // mirrored locally by the native SyncWorker, and whether its periodic - // background runs are allowed on cellular (default Wi-Fi-only). See - // SyncEngine.kt/server.md. - List _syncedPaths = []; - bool _syncEverything = false; - bool _syncOnCellular = false; - - // Live device-sync status, pushed from SyncStatusBus.kt via - // SyncService.statusStream (see _subscribeToSyncStatus) - account-wide, - // not scoped to whatever folder is currently browsed. - bool _isSyncingNow = false; - Set _syncingFileIds = {}; - Set _syncedFileIds = {}; - List _syncConflicts = []; - StreamSubscription? _syncStatusSub; - - // App lock (PIN/biometric via the device's own credential, not our own - // storage - see AppLockService). Global, not per-account: it guards - // access to the app/its accounts, not any one account's content. - bool _loginLockEnabled = false; - bool _lockAccountSwitching = false; - bool _lockHiddenFiles = false; - // Transient (never persisted) - starts locked whenever the app process - // starts, and re-locks on every backgrounding if a lock is configured; - // see didChangeAppLifecycleState. - bool _isUnlocked = false; - - // Navigation state - String _currentFolderPath = '/'; - - // A one-shot request for the shell to switch its active bottom-nav tab - // (e.g. a search result landing on Files) - consumed and cleared by - // MainShellView the next time it builds, not a persisted preference. - AppTab? _requestedTab; - - // Non-null while the app is acting as another app's GET_CONTENT picker - // (see PickIntentService/MainActivity.kt) - set at startup/onNewPickRequest - // in MainShellView, cleared once the pick is confirmed or cancelled. - PickRequest? _pickRequest; - bool _isDownloadingForPick = false; - List _pathStack = ['/']; - bool _isGridView = false; - bool _showFavoritesOnlyPhotos = false; - StorageScope _storageScope = StorageScope.cloud; - FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all; - bool _showHiddenFiles = false; - bool _showHiddenPhotos = false; - // Photos tab sort - a single global setting (Photos has no folder concept, - // it spans the whole account). - FileSortField _photosSortField = FileSortField.name; - bool _photosSortAscending = true; - - // Files tab sort - unlinked from Photos and remembered per folder path - // (like Windows Explorer's per-folder view settings), so switching - // folders can restore a different sort than the parent. Falls back to - // name/ascending for any folder with no saved entry. - final Map _folderSortField = {}; - final Map _folderSortAscending = {}; - - // Bottom nav tab configuration - List _tabOrder = AppTab.values.toList(); - Set _hiddenTabs = {}; - AppTab _defaultTab = AppTab.files; - - // Files list-view swipe actions - SwipeAction _swipeLeftAction = SwipeAction.delete; - SwipeAction _swipeRightAction = SwipeAction.favorite; - - // Folder-listing cache: keyed by folder path, holds nothing but the item - // list itself (no thumbnails/content) plus when it was fetched. - CachePolicy _cachePolicy = CachePolicy.never; - int _cacheIntervalMinutes = 5; - final Map _directoryCache = {}; - Timer? _cacheRefreshTimer; - - // Data state - List _items = []; - NextcloudUserQuota? _quota; - List _activities = []; - - // All-account media (the Photos tab) — fetched separately from [_items] - // since it spans every folder, not just the currently browsed one. - List _allMedia = []; - bool _isMediaLoading = false; - String? _mediaErrorMessage; - - // All-account favorites (the Favorites tab) - same idea as [_allMedia]: - // a strict filter (only favorited items) but an account-wide *scope*, so - // a favorited item several folders deep still shows up regardless of - // whether its parent folders are themselves favorited - which is exactly - // why this is its own tab (and its own fetch/loading state, kept - // separate from Files' own [_isLoading]/[_errorMessage] since both tabs - // are simultaneously mounted in MainShellView's IndexedStack and would - // otherwise bleed loading/error state into each other) rather than a - // filter toggle scoped to whatever folder the Files tab happens to be - // browsing. - List _allFavorites = []; - bool _isFavoritesLoading = false; - String? _favoritesErrorMessage; - // True once fetchAllFavorites has run at least once (i.e. the Favorites - // tab has been visited) - delete/rename/move/copy re-sync [_allFavorites] - // afterward, but only when it's actually been loaded, so those actions - // don't pay for an extra network round-trip on every Files/Photos edit - // for an account that's never opened the Favorites tab this session. - bool _favoritesEverFetched = false; - - // Trash state — kept separate from the regular folder-loading/error state - // above so a trash-fetch failure can't bleed a stale error into Files. - List _trashItems = []; - bool _isTrashLoading = false; - String? _trashErrorMessage; - - // Shares state — likewise kept separate. - List _shares = []; - bool _isSharesLoading = false; - String? _sharesErrorMessage; - bool _sharesWithMe = false; - - // Recent-files state — likewise kept separate. - List _recentItems = []; - bool _isRecentLoading = false; - String? _recentErrorMessage; - - NextcloudService? _service; - - // Multi-account state - final AccountStore _accountStore = AccountStore(); - List _accounts = []; - String? _activeAccountId; - // Bumped at the start of every account switch/removal/activation so an - // in-flight fetch from the account being left can recognize it's stale - // (by comparing against the generation it captured at its own start) and - // discard its result instead of writing it into the now-active account's - // state. - int _sessionGeneration = 0; - // UI-only signal for whether the in-progress login flow is "add another - // account" (started from Settings while already logged in) vs. the - // first/only login - ServerProvider itself doesn't branch persistence - // behavior on this, only the Add Account screen's own navigation does. - bool _addAccountFlowActive = false; - - ServerProvider() { - WidgetsBinding.instance.addObserver(this); - _init(); - _subscribeToSyncStatus(); - } - - Future _init() async { - final prefs = await _prefsFuture; - await _accountStore.migrateLegacyIfNeeded(prefs); - _accounts = _accountStore.loadAccounts(prefs); - _activeAccountId = _accountStore.loadActiveAccountId(prefs); - await Future.wait([_loadPreferences(), _restoreSession()]); - } - - /// Live device-sync status for the rest of the app's lifetime (not - /// re-subscribed per account switch - each event carries its own - /// `accountId`, so events for an account that isn't currently active are - /// just ignored below). Seeded once via a one-shot snapshot right after - /// every successful login/account-switch instead (see - /// `_applyCredentialsForAccount`), since events only arrive on change. - void _subscribeToSyncStatus() { - _syncStatusSub = SyncService.statusStream.listen((snapshot) { - _applySyncStatus(snapshot); - }); - } - - void _applySyncStatus(SyncStatusSnapshot snapshot) { - if (snapshot.accountId != null && snapshot.accountId != _activeAccountId) { - return; - } - _isSyncingNow = snapshot.syncing; - _syncingFileIds = snapshot.syncingFileIds; - _syncedFileIds = snapshot.syncedFileIds; - _syncConflicts = snapshot.conflicts; - notifyListeners(); - } - - /// Starts/stops the periodic folder-listing refresh as the app leaves and - /// returns to the foreground - only relevant under [CachePolicy.interval]. - /// Also re-locks the app on backgrounding when login lock is set up - a - /// one-time unlock at cold start would give the feature no real security - /// value, since the realistic threat is someone else picking up an - /// already-running, unlocked phone. - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _startCacheRefreshTimerIfNeeded(); - } else if (state == AppLifecycleState.paused) { - _cacheRefreshTimer?.cancel(); - if (_loginLockEnabled && _isUnlocked) { - _isUnlocked = false; - notifyListeners(); - } - } - } - - void _startCacheRefreshTimerIfNeeded() { - _cacheRefreshTimer?.cancel(); - if (_cachePolicy != CachePolicy.interval || !_isLoggedIn) return; - _cacheRefreshTimer = Timer.periodic( - Duration(minutes: _cacheIntervalMinutes), - (_) => refreshData(), - ); - } - - // Getters - String get serverUrl => _serverUrl; - String get username => _username; - bool get isLoggedIn => _isLoggedIn; - bool get isLoading => _isLoading; - bool get isRestoringSession => _isRestoringSession; - String? get errorMessage => _errorMessage; - - LoginFlowStatus get loginFlowStatus => _loginFlowStatus; - Uri? get pendingLoginUrl => _pendingLoginUrl; - - List get accounts => List.unmodifiable(_accounts); - String? get activeAccountId => _activeAccountId; - SavedAccount? get activeAccount => - _accounts.where((a) => a.id == _activeAccountId).firstOrNull; - bool get isAddAccountFlow => _addAccountFlowActive; - - Color get seedColor => _seedColor; - ThemeMode get themeMode => _themeMode; - bool get useDynamicColor => _useDynamicColor; - bool get amoledDark => _amoledDark; - MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; - double get bottomBarOpacity => _bottomBarOpacity; - double get bottomBarBlur => _bottomBarBlur; - bool get tapTabToScrollTop => _tapTabToScrollTop; - bool get loginLockEnabled => _loginLockEnabled; - bool get lockAccountSwitching => _lockAccountSwitching; - bool get lockHiddenFiles => _lockHiddenFiles; - bool get needsUnlock => _loginLockEnabled && !_isUnlocked; - - List get syncedPaths => List.unmodifiable(_syncedPaths); - bool get syncEverything => _syncEverything; - bool get syncOnCellular => _syncOnCellular; - - bool get isSyncingNow => _isSyncingNow; - List get syncConflicts => List.unmodifiable(_syncConflicts); - - SyncHeaderStatus get syncHeaderStatus { - if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert; - if (_isSyncingNow) return SyncHeaderStatus.syncing; - if (!_syncEverything && _syncedPaths.isEmpty) return SyncHeaderStatus.off; - return SyncHeaderStatus.done; - } - - SyncItemStatus syncStatusFor(NextcloudItem item) { - if (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing; - if (_syncConflicts.any((c) => c.fileId == item.id)) { - return SyncItemStatus.conflict; - } - if (_syncedFileIds.contains(item.id)) return SyncItemStatus.synced; - return SyncItemStatus.none; - } - - String get currentFolderPath => _currentFolderPath; - AppTab? get requestedTab => _requestedTab; - - PickRequest? get pickRequest => _pickRequest; - bool get isPicking => _pickRequest != null; - bool get isDownloadingForPick => _isDownloadingForPick; - - /// Whether [item] can be handed back to the app that's currently picking - /// - always true for folders (still browsable), mime-filtered for files. - bool itemMatchesPickFilter(NextcloudItem item) { - if (item.isFolder) return true; - final request = _pickRequest; - return request == null || request.matches(item.mimeType); - } - - /// Called once (from MainShellView) as soon as a pick request is known - - /// either the cold-start request or one that arrived via onNewPickRequest - /// while already running. - void setPickRequest(PickRequest request) { - _pickRequest = request; - notifyListeners(); - } - - /// Downloads each selected item to a scratch cache folder, then hands the - /// local paths back to the caller through PickIntentService, which closes - /// the picker Activity on success. Left in [_pickRequest] (i.e. picking - /// mode stays visually active) if the download fails partway, so the user - /// can see the error and retry rather than the screen finishing under - /// them with nothing returned to the caller. - Future confirmPick(List items) async { - final service = _service; - if (_pickRequest == null || service == null || items.isEmpty) { - return false; - } - _isDownloadingForPick = true; - notifyListeners(); - try { - final tempDir = await getTemporaryDirectory(); - final pickDir = Directory(p.join(tempDir.path, 'picker')); - final localPaths = []; - final mimeTypes = []; - for (final item in items) { - // Each item gets its own subfolder (keyed by id, not smashed into - // the filename) so two different items can share a plain file - // name without colliding, while the file on disk - and therefore - // the display name the caller sees via the content:// Uri - // MainActivity.kt hands back - stays exactly `item.name`. - final itemDir = Directory(p.join(pickDir.path, item.id)); - await itemDir.create(recursive: true); - final localPath = p.join(itemDir.path, item.name); - await service.downloadToFile(item.path, localPath); - localPaths.add(localPath); - mimeTypes.add(item.mimeType ?? 'application/octet-stream'); - } - await PickIntentService.finishPick(localPaths, mimeTypes); - _pickRequest = null; - return true; - } catch (_) { - return false; - } finally { - _isDownloadingForPick = false; - notifyListeners(); - } - } - - /// Backs out of picking mode entirely, telling the caller nothing was - /// picked and closing the picker Activity. - Future cancelPick() async { - if (_pickRequest == null) return; - _pickRequest = null; - notifyListeners(); - await PickIntentService.cancelPick(); - } - - /// Asks the shell to switch its active bottom-nav tab to [tab] - e.g. so - /// tapping a search result lands the user on the Files tab even if they - /// opened search from somewhere else. - void requestTab(AppTab tab) { - _requestedTab = tab; - notifyListeners(); - } - - /// Called by the shell once it's consumed [requestedTab]. - void consumeRequestedTab() { - _requestedTab = null; - } - - List get pathStack => _pathStack; - bool get isGridView => _isGridView; - bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos; - StorageScope get storageScope => _storageScope; - FilesTypeFilter get filesTypeFilter => _filesTypeFilter; - bool get showHiddenFiles => _showHiddenFiles; - bool get showHiddenPhotos => _showHiddenPhotos; - FileSortField get photosSortField => _photosSortField; - bool get photosSortAscending => _photosSortAscending; - FileSortField get filesSortField => - _folderSortField[_currentFolderPath] ?? FileSortField.name; - bool get filesSortAscending => - _folderSortAscending[_currentFolderPath] ?? true; - CachePolicy get cachePolicy => _cachePolicy; - int get cacheIntervalMinutes => _cacheIntervalMinutes; - - /// Every tab in the user's configured order, including hidden ones — used - /// by the reorder/visibility settings UI. - List get tabOrder => _tabOrder; - Set get hiddenTabs => _hiddenTabs; - AppTab get defaultTab => _defaultTab; - - /// The tabs the bottom nav bar should actually show, in order. - List get visibleTabs => - _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); - - SwipeAction get swipeLeftAction => _swipeLeftAction; - SwipeAction get swipeRightAction => _swipeRightAction; - - /// Exposes the active service so views can build file URLs, auth headers, - /// and trigger downloads/previews directly. - NextcloudService? get service => _service; - - int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) { - switch (field) { - case FileSortField.name: - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - case FileSortField.dateCreated: - return a.dateCreated.compareTo(b.dateCreated); - case FileSortField.dateModified: - return a.lastModified.compareTo(b.lastModified); - case FileSortField.size: - return a.size.compareTo(b.size); - } - } - - /// True if [item] or any ancestor folder in its path is a dotfile/dotfolder. - bool _isHiddenItem(NextcloudItem item) { - return item.path - .split('/') - .where((segment) => segment.isNotEmpty) - .any((segment) => segment.startsWith('.')); - } - - /// Applies the shared favorites-only/hidden-files/storage-scope toggles. - /// `showFavoritesOnly` is effectively Photos-only now (`items`/ - /// `favoriteItems` both always pass `false` - see their own doc - /// comments for why); kept as a parameter here since `photoItems` still - /// has its own independent favorites-only toggle. - List _applyCommonFilters( - List source, { - required bool showFavoritesOnly, - required bool showHidden, - }) { - var filtered = source; - if (showFavoritesOnly) { - filtered = filtered.where((item) => item.isFavorite).toList(); - } - if (!showHidden) { - filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); - } - filtered = filtered - .where( - (item) => _storageScope == StorageScope.external - ? item.isExternalStorage - : !item.isExternalStorage, - ) - .toList(); - return filtered; - } - - List get items => applyFilesDisplayPrefs(_items); - - /// The Favorites tab's content - every favorited item account-wide (see - /// `fetchAllFavorites`), run through the exact same hidden/type-filter/ - /// sort/storage-scope display prefs as the Files tab (deliberately - /// shared rather than a separate parallel settings dimension, the same - /// way the Move/Copy destination picker reuses them). No favorites - /// filter needed here - the source list is already all-favorites. - List get favoriteItems => - applyFilesDisplayPrefs(_allFavorites); - - /// Applies the Files tab's current sort/filter display prefs (hidden, - /// type filter, storage scope, sort field/direction) to an arbitrary raw - /// item list - factored out of the [items] getter so both the Favorites - /// tab ([favoriteItems]) and the Move/Copy destination picker (which - /// fetches its own listings via [fetchFolderListing] rather than reading - /// [items] itself - see that method's doc comment) can render with the - /// exact same controls/behavior as the Files tab without duplicating - /// this logic. - List applyFilesDisplayPrefs(List rawItems) { - var filtered = _applyCommonFilters( - rawItems, - // Files itself no longer filters by favorite - that's the dedicated - // Favorites tab's job now (see `favoriteItems`/`fetchAllFavorites`). - showFavoritesOnly: false, - showHidden: _showHiddenFiles, - ); - switch (_filesTypeFilter) { - case FilesTypeFilter.all: - break; - case FilesTypeFilter.filesOnly: - filtered = filtered.where((i) => !i.isFolder).toList(); - case FilesTypeFilter.foldersOnly: - filtered = filtered.where((i) => i.isFolder).toList(); - } - - final field = filesSortField; - final folders = filtered.where((i) => i.isFolder).toList() - ..sort((a, b) => _compareItems(a, b, field)); - final files = filtered.where((i) => !i.isFolder).toList() - ..sort((a, b) => _compareItems(a, b, field)); - return filesSortAscending - ? [...folders, ...files] - : [...folders.reversed, ...files.reversed]; - } - - /// All images/videos across the whole account (not just the currently - /// browsed folder) — see [fetchAllMedia]. - List get photoItems { - final media = _allMedia.where((i) => i.isMedia).toList(); - final filtered = _applyCommonFilters( - media, - showFavoritesOnly: _showFavoritesOnlyPhotos, - showHidden: _showHiddenPhotos, - )..sort((a, b) => _compareItems(a, b, _photosSortField)); - return _photosSortAscending ? filtered : filtered.reversed.toList(); - } - - bool get isMediaLoading => _isMediaLoading; - String? get mediaErrorMessage => _mediaErrorMessage; - bool get isFavoritesLoading => _isFavoritesLoading; - String? get favoritesErrorMessage => _favoritesErrorMessage; - - NextcloudUserQuota? get quota => _quota; - List get activities => _activities; - - List get trashItems => _trashItems; - bool get isTrashLoading => _isTrashLoading; - String? get trashErrorMessage => _trashErrorMessage; - - List get shares => _shares; - bool get isSharesLoading => _isSharesLoading; - String? get sharesErrorMessage => _sharesErrorMessage; - bool get sharesWithMe => _sharesWithMe; - - List get recentItems => _recentItems; - bool get isRecentLoading => _isRecentLoading; - String? get recentErrorMessage => _recentErrorMessage; - - Future _restoreSession() async { - try { - final id = _activeAccountId; - if (id == null) return; - final account = _accounts.where((a) => a.id == id).firstOrNull; - if (account == null) return; - final password = await _accountStore.readPassword(id); - if (password == null) return; - await _applyCredentialsForAccount(account, password); - } catch (e) { - debugPrint('[ServerProvider] Session restore failed: $e'); - } finally { - _isRestoringSession = false; - notifyListeners(); - } - } - - Future _loadPreferences() async { - try { - final prefs = await _prefsFuture; - - final themeModeName = prefs.getString(_prefThemeMode); - if (themeModeName != null) { - _themeMode = ThemeMode.values.firstWhere( - (m) => m.name == themeModeName, - orElse: () => ThemeMode.system, - ); - } - _useDynamicColor = - prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; - _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; - final progressBarStyleName = prefs.getString(_prefMediaProgressBarStyle); - if (progressBarStyleName != null) { - _mediaProgressBarStyle = MediaProgressBarStyle.values.firstWhere( - (s) => s.name == progressBarStyleName, - orElse: () => _mediaProgressBarStyle, - ); - } - final seedColorValue = prefs.getInt(_prefSeedColor); - if (seedColorValue != null) _seedColor = Color(seedColorValue); - _bottomBarOpacity = - prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity; - _bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur; - _tapTabToScrollTop = - prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop; - _loginLockEnabled = - prefs.getBool(_prefLoginLockEnabled) ?? _loginLockEnabled; - _lockAccountSwitching = - prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching; - _lockHiddenFiles = - prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles; - _syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular; - _applyAccountPrefs(prefs, _activeAccountId); - - final savedOrderNames = prefs.getStringList(_prefTabOrder); - if (savedOrderNames != null) { - final order = []; - for (final name in savedOrderNames) { - final match = AppTab.values.where((t) => t.name == name).firstOrNull; - if (match != null) order.add(match); - } - // Forward-compat: a tab added in a later app update won't be in an - // older saved order yet, so append anything missing. - for (final tab in AppTab.values) { - if (!order.contains(tab)) order.add(tab); - } - _tabOrder = order; - } - - final savedHiddenNames = prefs.getStringList(_prefHiddenTabs); - if (savedHiddenNames != null) { - _hiddenTabs = savedHiddenNames - .map( - (name) => AppTab.values.where((t) => t.name == name).firstOrNull, - ) - .whereType() - .toSet(); - // Never let every tab end up hidden. - if (_hiddenTabs.length >= AppTab.values.length) { - _hiddenTabs = {}; - } - } - - final savedDefaultName = prefs.getString(_prefDefaultTab); - if (savedDefaultName != null) { - final match = AppTab.values - .where((t) => t.name == savedDefaultName) - .firstOrNull; - if (match != null) _defaultTab = match; - } - if (_hiddenTabs.contains(_defaultTab)) { - _defaultTab = _tabOrder.firstWhere( - (t) => !_hiddenTabs.contains(t), - orElse: () => _tabOrder.first, - ); - } - _enforceMaxVisibleTabs(); - - final swipeLeftName = prefs.getString(_prefSwipeLeftAction); - if (swipeLeftName != null) { - _swipeLeftAction = SwipeAction.values.firstWhere( - (a) => a.name == swipeLeftName, - orElse: () => _swipeLeftAction, - ); - } - final swipeRightName = prefs.getString(_prefSwipeRightAction); - if (swipeRightName != null) { - _swipeRightAction = SwipeAction.values.firstWhere( - (a) => a.name == swipeRightName, - orElse: () => _swipeRightAction, - ); - } - _startCacheRefreshTimerIfNeeded(); - - notifyListeners(); - } catch (e) { - debugPrint('[ServerProvider] Preference restore failed: $e'); - } - } - - /// (Re)loads every per-account browsing pref (grid/list view, - /// favorites-only, storage scope, show-hidden, sort, folder sort, cache - /// policy) for [accountId] - called once at startup and again on every - /// account switch. Resets to defaults when [accountId] is null (no saved - /// account yet). - void _applyAccountPrefs(SharedPreferences prefs, String? accountId) { - _folderSortField.clear(); - _folderSortAscending.clear(); - - if (accountId == null) { - _isGridView = false; - _showFavoritesOnlyPhotos = false; - _storageScope = StorageScope.cloud; - _filesTypeFilter = FilesTypeFilter.all; - _showHiddenFiles = false; - _showHiddenPhotos = false; - _photosSortField = FileSortField.name; - _photosSortAscending = true; - _cachePolicy = CachePolicy.never; - _cacheIntervalMinutes = 5; - _syncedPaths = []; - _syncEverything = false; - return; - } - - String k(String base) => _accountStore.accountPrefKey(accountId, base); - - _isGridView = prefs.getBool(k(_prefGridView)) ?? false; - _showFavoritesOnlyPhotos = - prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false; - - final storageScopeName = prefs.getString(k(_prefStorageScope)); - _storageScope = StorageScope.values.firstWhere( - (s) => s.name == storageScopeName, - orElse: () => StorageScope.cloud, - ); - - final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter)); - _filesTypeFilter = FilesTypeFilter.values.firstWhere( - (f) => f.name == filesTypeFilterName, - orElse: () => FilesTypeFilter.all, - ); - - _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; - _showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; - - final sortFieldName = prefs.getString(k(_prefSortField)); - _photosSortField = FileSortField.values.firstWhere( - (f) => f.name == sortFieldName, - orElse: () => FileSortField.name, - ); - _photosSortAscending = prefs.getBool(k(_prefSortAscending)) ?? true; - - final folderSortJson = prefs.getString(k(_prefFolderSort)); - if (folderSortJson != null) { - try { - final decoded = jsonDecode(folderSortJson) as Map; - for (final entry in decoded.entries) { - final value = entry.value as Map; - final fieldName = value['field'] as String?; - if (fieldName != null) { - _folderSortField[entry.key] = FileSortField.values.firstWhere( - (f) => f.name == fieldName, - orElse: () => FileSortField.name, - ); - } - final ascending = value['ascending'] as bool?; - if (ascending != null) _folderSortAscending[entry.key] = ascending; - } - } catch (e) { - debugPrint('[ServerProvider] Folder sort restore failed: $e'); - } - } - - final cachePolicyName = prefs.getString(k(_prefCachePolicy)); - _cachePolicy = CachePolicy.values.firstWhere( - (c) => c.name == cachePolicyName, - orElse: () => CachePolicy.never, - ); - _cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5; - - final syncedFoldersJson = prefs.getString(k(_prefSyncedFolders)); - if (syncedFoldersJson != null) { - try { - _syncedPaths = (jsonDecode(syncedFoldersJson) as List).cast(); - } catch (e) { - debugPrint('[ServerProvider] Synced folders restore failed: $e'); - _syncedPaths = []; - } - } else { - _syncedPaths = []; - } - _syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false; - } - - /// Verifies [appPassword] for [account] and, on success, makes it the - /// live session. The password is expected to already be durably saved by - /// the caller (either freshly, via [_completeLoginFlow], or previously, - /// since this is also how a saved session is restored/switched to) - - /// this method only writes to [AccountStore] to drop a password that - /// turns out to no longer work. - Future _applyCredentialsForAccount( - SavedAccount account, - String appPassword, - ) async { - final gen = _sessionGeneration; - _isLoading = true; - _errorMessage = null; - notifyListeners(); - - _serverUrl = account.serverUrl; - _username = account.username; - _password = appPassword; - - _service = NextcloudService( - serverUrl: _serverUrl, - username: _username, - password: _password, - ); - - try { - final success = await _service!.testConnection(); - if (gen != _sessionGeneration) return false; - if (success) { - _isLoggedIn = true; - _currentFolderPath = '/'; - _pathStack = ['/']; - _startCacheRefreshTimerIfNeeded(); - await refreshData(); - if (gen != _sessionGeneration) return false; - _isLoading = false; - notifyListeners(); - unawaited(SyncService.reschedule(this)); - unawaited(SyncService.getStatus().then(_applySyncStatus)); - return true; - } - } catch (e) { - if (gen != _sessionGeneration) return false; - _errorMessage = e.toString().replaceAll('Exception: ', ''); - // Only drop the stored password on an actual auth rejection (401) - - // NextcloudService.testConnection also throws for network-level - // failures (DNS, timeout, unreachable host), and those are transient: - // deleting a still-valid password on a dropped connection would - // permanently log the account out with no way back in short of - // Login Flow v2 again, since _activeAccountId is never cleared and - // switchAccount() no-ops when asked to "switch" to the account - // that's already (nominally) active. - if (_errorMessage?.contains('401') == true) { - await _accountStore.deletePassword(account.id); - } - } - - if (gen != _sessionGeneration) return false; - _isLoggedIn = false; - _service = null; - _isLoading = false; - notifyListeners(); - return false; - } - - /// Starts Nextcloud Login Flow v2: asks the server for a one-time login - /// URL, then polls until the user authorizes (in LoginWebViewView, pushed - /// by LoginView once loginFlowStatus flips to awaitingBrowser below) and - /// the server hands back a scoped app password. The app never sees the - /// user's real password. [addAccount] only marks the flow as "add - /// another account" for [isAddAccountFlow] - the pushed Add Account - /// screen reads that to decide when to pop itself; this method's own - /// persistence behavior on success ([_completeLoginFlow]) is the same - /// either way (create-or-refresh the resulting account, then activate - /// it). - /// - /// Every login - first account or an additional one - goes through - /// LoginWebViewView (`package:webview_flutter`), a screen this app owns, - /// rather than a Chrome Custom Tab. That used to only be true for - /// add-account, specifically to avoid a Custom Tab silently reusing - /// Chrome's existing session for a different account; but a Custom Tab - /// has real costs even for the first login - no way to close it - /// automatically on success (the user has to switch back manually), and - /// it's a separate task outside this app's own navigation entirely. The - /// only thing it bought over a plain WebView was Chrome's own - /// autofill/saved-password support, which isn't worth those tradeoffs. - Future startLoginFlow( - String serverUrl, { - bool addAccount = false, - }) async { - _addAccountFlowActive = addAccount; - _loginFlowStatus = LoginFlowStatus.initiating; - _errorMessage = null; - notifyListeners(); - - try { - final init = await LoginFlowService.initiate(serverUrl); - _pendingLoginUrl = init.loginUrl; - _loginFlowStatus = LoginFlowStatus.awaitingBrowser; - notifyListeners(); - - _pollTimer?.cancel(); - _pollTimeoutTimer?.cancel(); - - _pollTimeoutTimer = Timer(const Duration(minutes: 10), () { - if (_loginFlowStatus == LoginFlowStatus.awaitingBrowser) { - cancelLoginFlow(errorMessage: 'Login timed out. Please try again.'); - } - }); - - _pollTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { - try { - final result = await LoginFlowService.poll( - init.pollEndpoint, - init.pollToken, - ); - if (result != null) { - timer.cancel(); - _pollTimeoutTimer?.cancel(); - _loginFlowStatus = LoginFlowStatus.idle; - _pendingLoginUrl = null; - await _completeLoginFlow(result); - _addAccountFlowActive = false; - } - } on http.ClientException catch (e) { - // A single dropped connection (e.g. the network briefly - // reconfiguring right as the login browser opens) used to abort - // the whole flow, forcing the user to start over even though - // they'd already granted access - just skip this tick and let - // the next scheduled poll (2s later) try again. The 10-minute - // timeout above is still the real ceiling. - debugPrint('[ServerProvider] Poll network hiccup, retrying: $e'); - } catch (e) { - timer.cancel(); - _pollTimeoutTimer?.cancel(); - cancelLoginFlow( - errorMessage: e.toString().replaceAll('Exception: ', ''), - ); - } - }); - } catch (e) { - _loginFlowStatus = LoginFlowStatus.error; - _errorMessage = e.toString().replaceAll('Exception: ', ''); - notifyListeners(); - } - } - - void cancelLoginFlow({String? errorMessage}) { - _pollTimer?.cancel(); - _pollTimeoutTimer?.cancel(); - _pendingLoginUrl = null; - _addAccountFlowActive = false; - _loginFlowStatus = errorMessage != null - ? LoginFlowStatus.error - : LoginFlowStatus.idle; - _errorMessage = errorMessage; - notifyListeners(); - } - - /// Turns a completed Login Flow v2 result into a saved account (creating - /// it, or refreshing its password if it already existed - e.g. an - /// expired app password re-authenticated) and makes it the active - /// session. Used for both the first/only login and "add account". - Future _completeLoginFlow(LoginFlowResult result) async { - final serverUrl = LoginFlowService.normalizeServerUrl(result.serverUrl); - final id = SavedAccount.makeId(serverUrl, result.loginName); - final account = SavedAccount( - id: id, - serverUrl: serverUrl, - username: result.loginName, - ); - _accounts = [ - for (final a in _accounts) - if (a.id != id) a, - account, - ]; - - final prefs = await _prefsFuture; - await _accountStore.saveAccounts(prefs, _accounts); - await _accountStore.writePassword(id, result.appPassword); - await _activateAccount(id); - } - - /// Clears every field that holds the *content* of whichever account is - /// currently active (files/photos/trash/shares/recent/service) - shared - /// by [_activateAccount] (about to load a different account's content) - /// and [removeAccount]'s "no accounts left" path (nothing left to load). - /// Deliberately does not touch [_isLoggedIn] - that's the caller's call. - void _clearActiveContent() { - _items = []; - _quota = null; - _activities = []; - _allMedia = []; - _isMediaLoading = false; - _mediaErrorMessage = null; - _trashItems = []; - _isTrashLoading = false; - _trashErrorMessage = null; - _shares = []; - _isSharesLoading = false; - _sharesErrorMessage = null; - _sharesWithMe = false; - _recentItems = []; - _isRecentLoading = false; - _recentErrorMessage = null; - _currentFolderPath = '/'; - _pathStack = ['/']; - _service = null; - } - - /// The shared engine behind switching accounts, falling back to another - /// account after removing the active one, and landing on the newly - /// created/refreshed account after a login flow completes: tears down - /// the outgoing account's live content (without ever setting - /// [isLoggedIn] false - see below), then loads the target account's own - /// prefs and credentials. - /// - /// Non-goal, by design: no simultaneous multi-account state. This is a - /// full teardown-and-reload of the active session every time, exactly - /// like today's single-account [logout] already did - just without - /// touching any *other* saved account's stored credentials/prefs. - Future _activateAccount(String accountId) async { - // Invalidates any fetch still in flight for the account being left, so - // a slow response can't land in the new account's state - see the - // `gen != _sessionGeneration` checks in refreshData/fetchAllMedia/ - // fetchTrash/fetchShares/fetchRecent/_applyCredentialsForAccount. - _sessionGeneration++; - // A pending "add account" flow can't stay pending through a manual - // switch/cycle - simplicity over blocking the gesture. - if (_loginFlowStatus != LoginFlowStatus.idle) cancelLoginFlow(); - - _cacheRefreshTimer?.cancel(); - _directoryCache.clear(); - _clearActiveContent(); - // Not `_isLoggedIn = false` - that would bounce main.dart's root - // routing through LoginView mid-switch. Each tab already shows its own - // spinner from `_isLoading`/`_isXLoading`, so this alone is enough to - // avoid flashing the outgoing account's stale content. - _isLoading = true; - notifyListeners(); - - _activeAccountId = accountId; - final prefs = await _prefsFuture; - await _accountStore.saveActiveAccountId(prefs, accountId); - _applyAccountPrefs(prefs, accountId); - notifyListeners(); - - final account = _accounts.where((a) => a.id == accountId).firstOrNull; - final password = account == null - ? null - : await _accountStore.readPassword(accountId); - if (account == null || password == null) { - _isLoading = false; - _isLoggedIn = false; - notifyListeners(); - return; - } - - await _applyCredentialsForAccount(account, password); - // The active account's Files listing is loaded synchronously above - // (inside _applyCredentialsForAccount -> refreshData); the other tabs - // stay mounted across the switch (MainShellView's IndexedStack) so - // their one-shot initState fetches won't naturally re-run - kick them - // off here instead. - unawaited(fetchAllMedia()); - unawaited(fetchTrash()); - unawaited(fetchShares()); - unawaited(fetchRecent()); - } - - /// Switches to an already-saved account. No-op (returns true) if it's - /// already active and logged in; no-op (returns false) if unknown - but - /// if [accountId] is nominally "active" while [isLoggedIn] is false (a - /// session-restore that failed, e.g. transient network trouble at cold - /// start), this still retries rather than no-op, since that's exactly the - /// case LoginView's "Continue as" tile exists to recover from. Gated - /// behind login lock when [lockAccountSwitching] is on. Returns whether - /// the account ended up logged in, so callers (LoginView's saved-account - /// tile) can surface a failure - e.g. a stored app password that no - /// longer works and needs the account removed/re-added. - Future switchAccount(String accountId) async { - if (accountId == _activeAccountId && _isLoggedIn) return true; - if (!_accounts.any((a) => a.id == accountId)) return false; - if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) { - return false; - } - await _activateAccount(accountId); - return _isLoggedIn; - } - - Future _cycleAccount(int direction) async { - if (_accounts.length < 2) return null; - if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) { - return null; - } - final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId); - final targetIndex = currentIndex == -1 - ? 0 - : (currentIndex + direction) % _accounts.length; - final target = - _accounts[(targetIndex + _accounts.length) % _accounts.length]; - unawaited(_activateAccount(target.id)); - return target; - } - - /// Cycles to the next/previous saved account (by the order they were - /// added) - used by the avatar's swipe-up/down quick-switch gesture. - /// Returns the account it's switching to once any login-lock gate has - /// passed (the switch's own network verification is still fire-and-forget - /// after that, same as before), or null if there's fewer than 2 saved - /// accounts or the gate was not passed. - Future cycleToNextAccount() => _cycleAccount(1); - Future cycleToPreviousAccount() => _cycleAccount(-1); - - /// Removes a saved account entirely: its stored password, its - /// namespaced prefs, and its entry in the saved-accounts list. If it was - /// the active account, falls back to another saved account, or - if none - /// remain - deactivates the session (the only path here that sets - /// [isLoggedIn] false; unlike [logout], there's nothing left to keep). - Future removeAccount(String accountId) async { - final index = _accounts.indexWhere((a) => a.id == accountId); - if (index == -1) return; - final wasActive = accountId == _activeAccountId; - - _accounts = [..._accounts]..removeAt(index); - final prefs = await _prefsFuture; - await _accountStore.saveAccounts(prefs, _accounts); - await _accountStore.deletePassword(accountId); - for (final key in AccountStore.perAccountPrefKeys) { - await prefs.remove(_accountStore.accountPrefKey(accountId, key)); - } - - if (!wasActive) { - notifyListeners(); - return; - } - - if (_accounts.isEmpty) { - await _deactivateSession(); - return; - } - - await _activateAccount(_accounts.first.id); - } - - /// Clears the active session pointer and tears down its content, without - /// touching any saved account's own data - shared by [logout] (which - /// deliberately keeps the account around to resume later, no - /// re-authentication needed) and [removeAccount]'s "nothing left to fall - /// back to" branch (where the account's data has already been deleted by - /// the time this runs). - Future _deactivateSession() async { - _sessionGeneration++; - _cacheRefreshTimer?.cancel(); - _directoryCache.clear(); - _clearActiveContent(); - _isLoggedIn = false; - _serverUrl = ''; - _username = ''; - _password = ''; - _activeAccountId = null; - final prefs = await _prefsFuture; - await _accountStore.saveActiveAccountId(prefs, null); - _applyAccountPrefs(prefs, null); - _isSyncingNow = false; - _syncingFileIds = {}; - _syncedFileIds = {}; - _syncConflicts = []; - notifyListeners(); - unawaited(SyncService.cancel()); - } - - /// Ends the active session but keeps this account saved - unlike - /// [removeAccount], nothing is deleted (password, prefs, its entry in - /// [accounts] all remain), so it's available to resume with a single tap - /// from the login screen's saved-accounts list, no Login Flow v2 needed. - /// Always lands on the login screen even if other accounts are saved - - /// deliberately not the same as switching to one of them. - Future logout() async { - if (_activeAccountId == null) return; - await _deactivateSession(); - } - - Future refreshData() async { - if (!_isLoggedIn || _service == null) { - debugPrint( - '[ServerProvider] refreshData skipped: isLoggedIn=$_isLoggedIn, service=${_service != null}', - ); - return; - } - // Captured so a response landing after an account switch mid-flight - // can recognize it's for an account the user has already left and - // discard itself instead of overwriting the new account's content. - final gen = _sessionGeneration; - - _isLoading = true; - _errorMessage = null; - notifyListeners(); - - debugPrint( - '[ServerProvider] Refreshing data for path: $_currentFolderPath', - ); - - try { - final items = await _service!.fetchDirectory(_currentFolderPath); - if (gen != _sessionGeneration) return; - _items = items; - _directoryCache[_currentFolderPath] = _CachedDirectory( - _items, - DateTime.now(), - ); - debugPrint( - '[ServerProvider] Loaded ${_items.length} items for $_currentFolderPath', - ); - try { - _quota = await _service!.fetchUserQuota(); - debugPrint( - '[ServerProvider] Loaded quota: used=${_quota?.usedBytes}, total=${_quota?.totalBytes}', - ); - } catch (e) { - debugPrint('[ServerProvider] Quota fetch warning: $e'); - } - try { - _activities = await _service!.fetchActivities(); - debugPrint( - '[ServerProvider] Loaded ${_activities.length} activity items', - ); - } catch (e) { - debugPrint('[ServerProvider] Activity fetch warning: $e'); - } - } catch (e) { - if (gen != _sessionGeneration) return; - debugPrint('[ServerProvider] Error fetching directory: $e'); - _errorMessage = e.toString().replaceAll('Exception: ', ''); - } finally { - if (gen == _sessionGeneration) { - _isLoading = false; - notifyListeners(); - } - } - } - - Future> searchFiles(String query) async { - if (_service == null) return []; - return _service!.searchFiles(query); - } - - /// Loads every image/video across the whole account for the Photos tab. - Future fetchAllMedia() async { - if (!_isLoggedIn || _service == null) return; - final gen = _sessionGeneration; - - _isMediaLoading = true; - _mediaErrorMessage = null; - notifyListeners(); - - try { - final media = await _service!.fetchAllMedia(); - if (gen != _sessionGeneration) return; - _allMedia = media; - debugPrint('[ServerProvider] Loaded ${_allMedia.length} media items'); - } catch (e) { - if (gen != _sessionGeneration) return; - debugPrint('[ServerProvider] Error fetching all media: $e'); - _mediaErrorMessage = e.toString().replaceAll('Exception: ', ''); - } finally { - if (gen == _sessionGeneration) { - _isMediaLoading = false; - notifyListeners(); - } - } - } - - /// Loads every favorited item across the whole account for the Favorites - /// tab - see [_allFavorites]'s doc comment for why this is a separate - /// account-wide fetch rather than a filter over the currently browsed - /// folder's [_items]. - Future fetchAllFavorites() async { - if (!_isLoggedIn || _service == null) return; - final gen = _sessionGeneration; - _favoritesEverFetched = true; - - _isFavoritesLoading = true; - _favoritesErrorMessage = null; - notifyListeners(); - - try { - final favorites = await _service!.fetchFavorites(); - if (gen != _sessionGeneration) return; - _allFavorites = favorites; - debugPrint('[ServerProvider] Loaded ${_allFavorites.length} favorites'); - } catch (e) { - if (gen != _sessionGeneration) return; - debugPrint('[ServerProvider] Error fetching favorites: $e'); - _favoritesErrorMessage = e.toString().replaceAll('Exception: ', ''); - } finally { - if (gen == _sessionGeneration) { - _isFavoritesLoading = false; - notifyListeners(); - } - } - } - - /// Re-syncs [_allFavorites] after a delete/rename/move/copy elsewhere in - /// the app (Files, Photos, or Favorites itself) - those operations don't - /// know how to patch [_allFavorites] in place (a move changes an item's - /// path; WebDAV COPY's handling of custom properties like `oc:favorite` - /// isn't reliable enough to assume the copy is still favorited), so this - /// just refetches - but only if Favorites has actually been loaded this - /// session, so an account that never opens that tab doesn't pay for an - /// extra request on every edit. - Future _syncFavoritesIfLoaded() { - return _favoritesEverFetched ? fetchAllFavorites() : Future.value(); - } - - Future navigateToFolder(String path) async { - _pathStack.add(path); - await _navigateTo(path); - } - - Future navigateUp() async { - if (_pathStack.length > 1) { - _pathStack.removeLast(); - await _navigateTo(_pathStack.last); - } - } - - /// Jumps directly to an ancestor folder by its position in [pathStack] - /// (as tapped from a breadcrumb), trimming everything below it. - Future navigateToPathIndex(int index) async { - if (index < 0 || index >= _pathStack.length - 1) return; - _pathStack = _pathStack.sublist(0, index + 1); - await _navigateTo(_pathStack.last); - } - - /// Navigates directly to an arbitrary absolute folder path (e.g. from a - /// search result) - unlike [navigateToFolder], which assumes [path] is a - /// child of wherever the user is currently browsing and just appends it, - /// this rebuilds the whole breadcrumb trail from root so it's correct - /// regardless of where the user was before. - Future navigateToAbsoluteFolder(String path) async { - final normalized = path.trim().replaceAll(RegExp(r'/+$'), ''); - final segments = normalized.split('/').where((s) => s.isNotEmpty).toList(); - final stack = ['/']; - var current = ''; - for (final segment in segments) { - current = '$current/$segment'; - stack.add(current); - } - _pathStack = stack; - await _navigateTo(stack.last); - } - - /// True if [path]'s cached listing (if any) is still usable under the - /// current [CachePolicy] - never for [CachePolicy.never], indefinitely - /// for [CachePolicy.manual], and until it's older than - /// [cacheIntervalMinutes] for [CachePolicy.interval]. - bool _isCacheFresh(String path) { - if (_cachePolicy == CachePolicy.never) return false; - final cached = _directoryCache[path]; - if (cached == null) return false; - if (_cachePolicy == CachePolicy.manual) return true; - return DateTime.now().difference(cached.fetchedAt) < - Duration(minutes: _cacheIntervalMinutes); - } - - /// Switches the current folder to [path], serving its listing straight - /// from the cache when that's still fresh (instant, no network call at - /// all) and only falling back to [refreshData] otherwise. - Future _navigateTo(String path) async { - _currentFolderPath = path; - final cached = _directoryCache[path]; - if (cached != null && _isCacheFresh(path)) { - debugPrint('[ServerProvider] Serving cached listing for $path'); - _items = cached.items; - _errorMessage = null; - notifyListeners(); - return; - } - await refreshData(); - } - - /// Persists a per-account browsing pref under its `acct__`-namespaced - /// key. No-op if there's no active account (shouldn't normally happen - - /// these setters are only reachable from screens that require one). - void _persistAccountPref( - String baseKey, - void Function(SharedPreferences prefs, String key) write, - ) { - final id = _activeAccountId; - if (id == null) return; - _prefsFuture.then( - (p) => write(p, _accountStore.accountPrefKey(id, baseKey)), - ); - } - - void setGridView(bool value) { - if (_isGridView == value) return; - _isGridView = value; - notifyListeners(); - _persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value)); - } - - void toggleFavoritesFilterPhotos() { - _showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos; - notifyListeners(); - _persistAccountPref( - _prefShowFavoritesOnlyPhotos, - (p, key) => p.setBool(key, _showFavoritesOnlyPhotos), - ); - } - - void setStorageScope(StorageScope scope) { - if (_storageScope == scope) return; - _storageScope = scope; - notifyListeners(); - _persistAccountPref( - _prefStorageScope, - (p, key) => p.setString(key, scope.name), - ); - } - - void setFilesTypeFilter(FilesTypeFilter filter) { - if (_filesTypeFilter == filter) return; - _filesTypeFilter = filter; - notifyListeners(); - _persistAccountPref( - _prefFilesTypeFilter, - (p, key) => p.setString(key, filter.name), - ); - } - - /// Only *enabling* hidden-files visibility is gated - hiding them again - /// never exposes anything, so that direction is always allowed instantly. - Future toggleShowHiddenFiles() async { - if (!_showHiddenFiles) { - if (!await _passGate(_lockHiddenFiles, 'Unlock to show hidden files')) { - return; - } - } - _showHiddenFiles = !_showHiddenFiles; - notifyListeners(); - _persistAccountPref( - _prefShowHiddenFiles, - (p, key) => p.setBool(key, _showHiddenFiles), - ); - } - - Future toggleShowHiddenPhotos() async { - if (!_showHiddenPhotos) { - if (!await _passGate(_lockHiddenFiles, 'Unlock to show hidden files')) { - return; - } - } - _showHiddenPhotos = !_showHiddenPhotos; - notifyListeners(); - _persistAccountPref( - _prefShowHiddenPhotos, - (p, key) => p.setBool(key, _showHiddenPhotos), - ); - } - - void setPhotosSortField(FileSortField field) { - if (_photosSortField == field) return; - _photosSortField = field; - notifyListeners(); - _persistAccountPref( - _prefSortField, - (p, key) => p.setString(key, field.name), - ); - } - - void togglePhotosSortOrder() { - _photosSortAscending = !_photosSortAscending; - notifyListeners(); - _persistAccountPref( - _prefSortAscending, - (p, key) => p.setBool(key, _photosSortAscending), - ); - } - - void _persistFolderSort() { - final combined = {}; - for (final path in { - ..._folderSortField.keys, - ..._folderSortAscending.keys, - }) { - combined[path] = { - 'field': _folderSortField[path]?.name, - 'ascending': _folderSortAscending[path], - }; - } - _persistAccountPref( - _prefFolderSort, - (p, key) => p.setString(key, jsonEncode(combined)), - ); - } - - void setFilesSortField(FileSortField field) { - if (filesSortField == field) return; - _folderSortField[_currentFolderPath] = field; - notifyListeners(); - _persistFolderSort(); - } - - void toggleFilesSortOrder() { - _folderSortAscending[_currentFolderPath] = !filesSortAscending; - notifyListeners(); - _persistFolderSort(); - } - - void _persistSyncedPaths() { - _persistAccountPref( - _prefSyncedFolders, - (p, key) => p.setString(key, jsonEncode(_syncedPaths)), - ); - } - - bool isPathSynced(String path) => _syncedPaths.contains(path); - - void addSyncedPath(String path) { - if (_syncedPaths.contains(path)) return; - _syncedPaths = [..._syncedPaths, path]; - notifyListeners(); - _persistSyncedPaths(); - unawaited(SyncService.reschedule(this)); - } - - void removeSyncedPath(String path) { - if (!_syncedPaths.contains(path)) return; - _syncedPaths = _syncedPaths.where((f) => f != path).toList(); - notifyListeners(); - _persistSyncedPaths(); - unawaited(SyncService.reschedule(this)); - } - - void setSyncEverything(bool value) { - if (_syncEverything == value) return; - _syncEverything = value; - notifyListeners(); - _persistAccountPref(_prefSyncEverything, (p, key) => p.setBool(key, value)); - unawaited(SyncService.reschedule(this)); - } - - void setSyncOnCellular(bool value) { - if (_syncOnCellular == value) return; - _syncOnCellular = value; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value)); - unawaited(SyncService.reschedule(this)); - } - - /// In-app conflict resolution (the sync header's "Keep local"/"Use - /// server" buttons) - see `SyncService.resolveConflict`'s doc comment - /// for why this shares the exact same native path the notification - /// actions use. - Future resolveSyncConflict( - SyncConflictInfo conflict, { - required bool useLocal, - }) { - return SyncService.resolveConflict( - this, - conflict, - useLocal ? 'local' : 'server', - ); - } - - /// The local mirror path for [item] if it falls under a synced folder - /// *and* has actually been synced down already - a pure function of the - /// remote path once a folder's marked synced (mirrors - /// `SyncEngine.kt#syncRoot`'s `/sync//...` - /// layout exactly), so this never needs to read the native sync-state - /// SharedPreferences from Dart. Callers (Share/Download) use this to - /// skip a fresh WebDAV fetch when a local copy already exists. - Future localSyncedFilePath(NextcloudItem item) async { - final id = activeAccountId; - if (id == null) return null; - final itemPath = item.path.endsWith('/') - ? item.path.substring(0, item.path.length - 1) - : item.path; - final isSynced = - _syncEverything || - _syncedPaths.any((folder) { - final f = folder.endsWith('/') - ? folder.substring(0, folder.length - 1) - : folder; - return itemPath == f || itemPath.startsWith('$f/'); - }); - if (!isSynced) return null; - final base = await getExternalStorageDirectory(); - if (base == null) return null; - final relPath = item.path.startsWith('/') - ? item.path.substring(1) - : item.path; - final file = File(p.join(base.path, 'sync', id, relPath)); - return file.existsSync() ? file.path : null; - } - - void setTabOrder(List order) { - _tabOrder = order; - notifyListeners(); - _prefsFuture.then( - (p) => p.setStringList(_prefTabOrder, order.map((t) => t.name).toList()), - ); - } - - /// Shows or hides a tab in the bottom nav bar. Refuses to hide the last - /// remaining visible tab. If the tab being hidden is the current default, - /// the default falls back to the next visible tab. - /// Returns null on success, or a user-facing reason the change was - /// refused (hiding the last visible tab, or showing a 6th). - String? setTabHidden(AppTab tab, bool hidden) { - if (hidden) { - final remaining = _tabOrder - .where((t) => t != tab && !_hiddenTabs.contains(t)) - .length; - if (remaining == 0) return 'At least one tab must stay visible'; - _hiddenTabs = {..._hiddenTabs, tab}; - if (_defaultTab == tab) { - _defaultTab = _tabOrder.firstWhere((t) => !_hiddenTabs.contains(t)); - _prefsFuture.then( - (p) => p.setString(_prefDefaultTab, _defaultTab.name), - ); - } - } else { - final currentlyVisible = _tabOrder - .where((t) => !_hiddenTabs.contains(t)) - .length; - if (currentlyVisible >= maxVisibleTabs) { - return 'You can only show up to $maxVisibleTabs tabs at once'; - } - _hiddenTabs = {..._hiddenTabs}..remove(tab); - } - notifyListeners(); - _prefsFuture.then( - (p) => p.setStringList( - _prefHiddenTabs, - _hiddenTabs.map((t) => t.name).toList(), - ), - ); - return null; - } - - /// If more than [maxVisibleTabs] end up visible (e.g. a fresh install, or - /// an existing saved config from before a new tab was added to the app), - /// hide the overflow automatically rather than exceeding the cap. - void _enforceMaxVisibleTabs() { - final visible = _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); - if (visible.length <= maxVisibleTabs) return; - _hiddenTabs = {..._hiddenTabs, ...visible.sublist(maxVisibleTabs)}; - if (_hiddenTabs.contains(_defaultTab)) { - _defaultTab = _tabOrder.firstWhere((t) => !_hiddenTabs.contains(t)); - } - } - - void setDefaultTab(AppTab tab) { - if (_hiddenTabs.contains(tab) || _defaultTab == tab) return; - _defaultTab = tab; - notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefDefaultTab, tab.name)); - } - - void setSwipeLeftAction(SwipeAction action) { - if (_swipeLeftAction == action) return; - _swipeLeftAction = action; - notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefSwipeLeftAction, action.name)); - } - - void setSwipeRightAction(SwipeAction action) { - if (_swipeRightAction == action) return; - _swipeRightAction = action; - notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name)); - } - - void setCachePolicy(CachePolicy policy) { - if (_cachePolicy == policy) return; - _cachePolicy = policy; - notifyListeners(); - _persistAccountPref( - _prefCachePolicy, - (p, key) => p.setString(key, policy.name), - ); - _startCacheRefreshTimerIfNeeded(); - } - - void setCacheIntervalMinutes(int minutes) { - final clamped = minutes.clamp(1, 60); - if (_cacheIntervalMinutes == clamped) return; - _cacheIntervalMinutes = clamped; - notifyListeners(); - _persistAccountPref( - _prefCacheIntervalMinutes, - (p, key) => p.setInt(key, clamped), - ); - _startCacheRefreshTimerIfNeeded(); - } - - Future deleteItem(String itemPath) async { - if (_service == null) return false; - final success = await _service!.deleteItem(itemPath); - if (success) { - _allMedia = _allMedia.where((i) => i.path != itemPath).toList(); - await refreshData(); - await _syncFavoritesIfLoaded(); - } - return success; - } - - Future renameItem(NextcloudItem item, String newName) async { - if (_service == null) return false; - final success = await _service!.renameItem(item.path, newName); - if (success) { - await refreshData(); - await _syncFavoritesIfLoaded(); - } - return success; - } - - /// A destination-picker-only fetch: the current folder listing state - /// (`items`/`currentFolderPath`/`pathStack`/`_directoryCache`) belongs to - /// whichever tab is actively browsing (Files), so the Move/Copy - /// destination picker deliberately doesn't touch any of it - it fetches - /// listings for its own local navigation state through this instead, - /// mirroring `searchFiles`'s identical stateless-pass-through shape. - Future> fetchFolderListing(String path) { - return _service?.fetchDirectory(path) ?? Future.value([]); - } - - /// True if [destFolderPath] is [folderPath] itself or one of its own - /// descendants - moving/copying a folder into itself (or a subfolder of - /// itself) is nonsensical and rejected client-side rather than left to - /// the server to (maybe) reject. - bool _isSelfOrDescendant(String destFolderPath, String folderPath) { - final dest = destFolderPath.endsWith('/') - ? destFolderPath - : '$destFolderPath/'; - final folder = folderPath.endsWith('/') ? folderPath : '$folderPath/'; - return dest == folder || dest.startsWith(folder); - } - - Future _moveOrCopyItems( - List items, - String destFolderPath, { - required bool copy, - }) async { - if (_service == null || items.isEmpty) return const MoveCopyResult(); - for (final item in items) { - if (item.isFolder && _isSelfOrDescendant(destFolderPath, item.path)) { - return const MoveCopyResult( - blockedReason: - "Can't move a folder into itself or one of its own subfolders", - ); - } - } - - var succeeded = 0; - var failed = 0; - final conflicts = []; - for (final item in items) { - final status = copy - ? await _service!.copyItem(item.path, destFolderPath) - : await _service!.moveItem(item.path, destFolderPath); - if (status == 201 || status == 204) { - succeeded++; - } else if (status == 412) { - conflicts.add(MoveCopyConflict(item)); - } else { - failed++; - } - } - if (succeeded > 0) { - _directoryCache.clear(); - await refreshData(); - await _syncFavoritesIfLoaded(); - } - return MoveCopyResult( - succeeded: succeeded, - conflicts: conflicts, - failed: failed, - ); - } - - /// Moves every item in [items] into [destFolderPath], keeping each - /// item's own filename. Items that would collide with something already - /// there come back in [MoveCopyResult.conflicts] rather than failing - /// outright - resolve those with [resolveConflicts]. - Future moveItems( - List items, - String destFolderPath, - ) { - return _moveOrCopyItems(items, destFolderPath, copy: false); - } - - /// Same as [moveItems] but duplicates rather than relocates. - Future copyItems( - List items, - String destFolderPath, - ) { - return _moveOrCopyItems(items, destFolderPath, copy: true); - } - - /// Finds the first `name (n).ext` not already in [existing] - `existing` - /// is mutated as names are claimed, so a batch of "keep both" resolutions - /// never picks the same generated name twice. - String _nextAvailableName(String name, Set existing) { - if (!existing.contains(name)) return name; - final dotIndex = name.lastIndexOf('.'); - final hasExtension = dotIndex > 0 && dotIndex < name.length - 1; - final base = hasExtension ? name.substring(0, dotIndex) : name; - final ext = hasExtension ? name.substring(dotIndex) : ''; - var n = 2; - while (existing.contains('$base ($n)$ext')) { - n++; - } - return '$base ($n)$ext'; - } - - /// Resolves a previous [moveItems]/[copyItems] call's conflicts per - /// [choices] (keyed by `item.id`): overwrite the existing item, keep - /// both (renamed against a fresh listing of [destFolderPath] so two - /// "keep both" picks in the same batch can't collide with each other), - /// or skip (left untouched at the source). - Future resolveConflicts( - List conflicts, - String destFolderPath, { - required bool copy, - required Map choices, - }) async { - if (_service == null || conflicts.isEmpty) return const MoveCopyResult(); - - final needsKeepBoth = conflicts.any( - (c) => choices[c.item.id] == ConflictChoice.keepBoth, - ); - final existingNames = needsKeepBoth - ? (await fetchFolderListing(destFolderPath)).map((i) => i.name).toSet() - : {}; - - var succeeded = 0; - var failed = 0; - for (final conflict in conflicts) { - final choice = choices[conflict.item.id] ?? ConflictChoice.skip; - if (choice == ConflictChoice.skip) continue; - - final int status; - if (choice == ConflictChoice.overwrite) { - status = copy - ? await _service!.copyItem( - conflict.item.path, - destFolderPath, - overwrite: true, - ) - : await _service!.moveItem( - conflict.item.path, - destFolderPath, - overwrite: true, - ); - } else { - final newName = _nextAvailableName(conflict.item.name, existingNames); - existingNames.add(newName); - status = copy - ? await _service!.copyItem( - conflict.item.path, - destFolderPath, - newName: newName, - ) - : await _service!.moveItem( - conflict.item.path, - destFolderPath, - newName: newName, - ); - } - if (status == 201 || status == 204) { - succeeded++; - } else { - failed++; - } - } - if (succeeded > 0) { - _directoryCache.clear(); - await refreshData(); - await _syncFavoritesIfLoaded(); - } - return MoveCopyResult(succeeded: succeeded, failed: failed); - } - - Future fetchTrash() async { - if (!_isLoggedIn || _service == null) return; - final gen = _sessionGeneration; - - _isTrashLoading = true; - _trashErrorMessage = null; - notifyListeners(); - - try { - final trash = await _service!.fetchTrash(); - if (gen != _sessionGeneration) return; - _trashItems = trash; - } catch (e) { - if (gen != _sessionGeneration) return; - debugPrint('[ServerProvider] Error fetching trash: $e'); - _trashErrorMessage = e.toString().replaceAll('Exception: ', ''); - } finally { - if (gen == _sessionGeneration) { - _isTrashLoading = false; - notifyListeners(); - } - } - } - - /// Restores a trashed item (identified by [NextcloudItem.path], the - /// trash-relative name) back to where it was deleted from. - Future restoreTrashItem(NextcloudItem item) async { - if (_service == null) return false; - final success = await _service!.restoreTrashItem(item.path); - if (success) { - _trashItems = _trashItems.where((i) => i.id != item.id).toList(); - notifyListeners(); - } - return success; - } - - /// Permanently deletes a trashed item. Cannot be undone. - Future deleteTrashItemForever(NextcloudItem item) async { - if (_service == null) return false; - final success = await _service!.deleteTrashItemForever(item.path); - if (success) { - _trashItems = _trashItems.where((i) => i.id != item.id).toList(); - notifyListeners(); - } - return success; - } - - Future fetchShares() async { - if (!_isLoggedIn || _service == null) return; - final gen = _sessionGeneration; - - _isSharesLoading = true; - _sharesErrorMessage = null; - notifyListeners(); - - try { - final shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe); - if (gen != _sessionGeneration) return; - _shares = shares; - } catch (e) { - if (gen != _sessionGeneration) return; - debugPrint('[ServerProvider] Error fetching shares: $e'); - _sharesErrorMessage = e.toString().replaceAll('Exception: ', ''); - } finally { - if (gen == _sessionGeneration) { - _isSharesLoading = false; - notifyListeners(); - } - } - } - - /// Switches between "shared by me" and "shared with me" and refetches. - void setSharesWithMe(bool value) { - if (_sharesWithMe == value) return; - _sharesWithMe = value; - notifyListeners(); - fetchShares(); - } - - Future deleteShare(NextcloudShare share) async { - if (_service == null) return false; - final success = await _service!.deleteShare(share.id); - if (success) { - _shares = _shares.where((s) => s.id != share.id).toList(); - notifyListeners(); - } - return success; - } - - Future fetchRecent() async { - if (!_isLoggedIn || _service == null) return; - final gen = _sessionGeneration; - - _isRecentLoading = true; - _recentErrorMessage = null; - notifyListeners(); - - try { - final recent = await _service!.fetchRecentFiles(); - if (gen != _sessionGeneration) return; - _recentItems = recent; - } catch (e) { - if (gen != _sessionGeneration) return; - debugPrint('[ServerProvider] Error fetching recent files: $e'); - _recentErrorMessage = e.toString().replaceAll('Exception: ', ''); - } finally { - if (gen == _sessionGeneration) { - _isRecentLoading = false; - notifyListeners(); - } - } - } - - Future createFolder(String folderName) async { - if (_service == null) return false; - final success = await _service!.createFolder( - _currentFolderPath, - folderName, - ); - if (success) { - await refreshData(); - } - return success; - } - - Future toggleItemFavorite(NextcloudItem item) async { - if (_service == null) return; - final success = await _service!.toggleFavorite(item.path, item.isFavorite); - if (success) { - final updated = item.copyWith(isFavorite: !item.isFavorite); - final index = _items.indexWhere((i) => i.id == item.id); - if (index != -1) _items[index] = updated; - final mediaIndex = _allMedia.indexWhere((i) => i.id == item.id); - if (mediaIndex != -1) _allMedia[mediaIndex] = updated; - // Keeps the Favorites tab's own list in sync in place (cheap enough - // not to need a full refetch, unlike delete/rename/move/copy - see - // `_syncFavoritesIfLoaded`) - added if newly favorited, removed if - // un-favorited, since [_allFavorites] should only ever hold - // favorited items. - final favIndex = _allFavorites.indexWhere((i) => i.id == item.id); - if (updated.isFavorite) { - if (favIndex != -1) { - _allFavorites[favIndex] = updated; - } else { - _allFavorites = [..._allFavorites, updated]; - } - } else if (favIndex != -1) { - _allFavorites = [..._allFavorites]..removeAt(favIndex); - } - notifyListeners(); - } - } - - /// Creates a public link share for [item] and returns its URL, or null on - /// failure (server error, unsupported item, etc). - Future createShareLink(NextcloudItem item) async { - if (_service == null) return null; - try { - return await _service!.createPublicShareLink(item.path); - } catch (e) { - debugPrint('[ServerProvider] createShareLink failed: $e'); - return null; - } - } - - // --- Details sheet: per-item sharing/activity/versions --- - // - // These are thin pass-throughs (no stored state, no notifyListeners) - - // unlike the flat account-wide lists above (`_shares`, `_trashItems`, - // ...), the data here is transient and scoped to whichever item's - // Details sheet happens to be open, so it lives in that sheet's own - // local widget state rather than bloating this provider. - - Future> fetchItemShares(NextcloudItem item) async { - if (_service == null) return []; - try { - return await _service!.fetchSharesForPath(item.path); - } catch (e) { - debugPrint('[ServerProvider] fetchItemShares failed: $e'); - return []; - } - } - - Future> fetchInheritedShares(NextcloudItem item) async { - if (_service == null) return []; - return _service!.fetchInheritedShares(item.path); - } - - Future> searchSharees(String query) async { - if (_service == null) return []; - try { - return await _service!.searchSharees(query); - } catch (e) { - debugPrint('[ServerProvider] searchSharees failed: $e'); - return []; - } - } - - Future createShare({ - required String path, - required int shareType, - String? shareWith, - String? password, - DateTime? expireDate, - }) async { - if (_service == null) return null; - try { - return await _service!.createShare( - path: path, - shareType: shareType, - shareWith: shareWith, - password: password, - expireDate: expireDate, - ); - } catch (e) { - debugPrint('[ServerProvider] createShare failed: $e'); - return null; - } - } - - Future> fetchFileActivity(NextcloudItem item) async { - if (_service == null) return []; - return _service!.fetchFileActivity(item.id); - } - - Future> fetchFileVersions( - NextcloudItem item, - ) async { - if (_service == null) return []; - return _service!.fetchFileVersions(item.id); - } - - Future restoreFileVersion( - NextcloudItem item, - String versionLabel, - ) async { - if (_service == null) return false; - try { - return await _service!.restoreFileVersion(item.id, versionLabel); - } catch (e) { - debugPrint('[ServerProvider] restoreFileVersion failed: $e'); - return false; - } - } - - Future downloadVersion( - NextcloudItem item, - String versionLabel, - String savePath, - ) async { - if (_service == null) return false; - try { - await _service!.downloadVersionToFile(item.id, versionLabel, savePath); - return true; - } catch (e) { - debugPrint('[ServerProvider] downloadVersion failed: $e'); - return false; - } - } - - void setSeedColor(Color color) { - _seedColor = color; - _useDynamicColor = false; - notifyListeners(); - _prefsFuture.then((p) { - p.setInt(_prefSeedColor, color.toARGB32()); - p.setBool(_prefUseDynamicColor, false); - }); - } - - void setUseDynamicColor(bool value) { - _useDynamicColor = value; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefUseDynamicColor, value)); - } - - void setAmoledDark(bool value) { - _amoledDark = value; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefAmoledDark, value)); - } - - void setMediaProgressBarStyle(MediaProgressBarStyle style) { - _mediaProgressBarStyle = style; - notifyListeners(); - _prefsFuture.then( - (p) => p.setString(_prefMediaProgressBarStyle, style.name), - ); - } - - void setBottomBarOpacity(double value) { - _bottomBarOpacity = value; - notifyListeners(); - _prefsFuture.then((p) => p.setDouble(_prefBottomBarOpacity, value)); - } - - void setBottomBarBlur(double value) { - _bottomBarBlur = value; - notifyListeners(); - _prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value)); - } - - void setTapTabToScrollTop(bool value) { - _tapTabToScrollTop = value; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value)); - } - - /// Confirms the device can do local auth and prompts once to enable login - /// lock. Returns false (leaving lock disabled) if the device has no - /// biometric/PIN capability or the user cancels/fails the confirmation. - Future setupLoginLock() async { - if (!await AppLockService.isDeviceSupported()) return false; - final confirmed = await AppLockService.authenticate( - 'Confirm to turn on login lock', - ); - if (!confirmed) return false; - _loginLockEnabled = true; - // Already just authenticated - don't immediately re-prompt behind it. - _isUnlocked = true; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, true)); - return true; - } - - /// Turns login lock off, along with both of its sub-toggles (meaningless - /// once the base lock is gone). Requires a successful auth first, same as - /// turning it on - otherwise anyone with momentary access to an unlocked - /// phone could just switch it off. - Future disableLoginLock() async { - if (!_loginLockEnabled) return true; - final confirmed = await AppLockService.authenticate( - 'Confirm to turn off login lock', - ); - if (!confirmed) return false; - _loginLockEnabled = false; - _lockAccountSwitching = false; - _lockHiddenFiles = false; - _isUnlocked = false; - notifyListeners(); - _prefsFuture.then((p) { - p.setBool(_prefLoginLockEnabled, false); - p.setBool(_prefLockAccountSwitching, false); - p.setBool(_prefLockHiddenFiles, false); - }); - return true; - } - - void setLockAccountSwitching(bool value) { - if (!_loginLockEnabled) return; - _lockAccountSwitching = value; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value)); - } - - void setLockHiddenFiles(bool value) { - if (!_loginLockEnabled) return; - _lockHiddenFiles = value; - notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value)); - } - - /// Called by the lock screen. Returns whether it actually unlocked. - Future attemptUnlock() async { - final success = await AppLockService.authenticate('Unlock Noo'); - if (success) { - _isUnlocked = true; - notifyListeners(); - } - return success; - } - - /// Prompts for auth if [gate] is on and login lock is configured; - /// returns true immediately (no prompt) otherwise. Shared by the - /// account-switching and hidden-files gates below. - Future _passGate(bool gate, String reason) async { - if (!_loginLockEnabled || !gate) return true; - return AppLockService.authenticate(reason); - } - - void setThemeMode(ThemeMode mode) { - _themeMode = mode; - notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _pollTimer?.cancel(); - _pollTimeoutTimer?.cancel(); - _cacheRefreshTimer?.cancel(); - _syncStatusSub?.cancel(); - super.dispose(); - } -} diff --git a/lib/providers/session_controller.dart b/lib/providers/session_controller.dart new file mode 100644 index 0000000..ac047bc --- /dev/null +++ b/lib/providers/session_controller.dart @@ -0,0 +1,571 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/saved_account.dart'; +import '../services/account_store.dart'; +import '../services/app_lock_service.dart'; +import '../services/login_flow_service.dart'; +import '../services/nextcloud_service.dart'; +import '../services/sync_service.dart'; + +enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } + +/// Owns everything about *which* account is live and whether it's actually +/// authenticated: the saved-accounts list, the active account, Login Flow +/// v2, the live [NextcloudService] instance, the session-generation guard +/// every other controller's fetches check against, and app lock. Doesn't +/// hold a single byte of any tab's *content* - that's what +/// [FilesController]/`PhotosController`/etc. own, each reacting to this +/// controller's account-activated/cleared signals (below) rather than this +/// controller reaching into them directly, since this is constructed +/// before them (they depend on it, not the other way around). +/// +/// Split out of the former single `ServerProvider` god object - see +/// `.claude/context/architecture.md`'s "State management" section for the +/// full rationale. +class SessionController extends ChangeNotifier with WidgetsBindingObserver { + final Future prefsFuture = + SharedPreferences.getInstance(); + final AccountStore accountStore = AccountStore(); + + String _serverUrl = ''; + String _username = ''; + String _password = ''; + bool _isLoggedIn = false; + bool _isLoading = false; + bool _isRestoringSession = true; + String? _errorMessage; + + LoginFlowStatus _loginFlowStatus = LoginFlowStatus.idle; + Uri? _pendingLoginUrl; + Timer? _pollTimer; + Timer? _pollTimeoutTimer; + bool _addAccountFlowActive = false; + + NextcloudService? _service; + + List _accounts = []; + String? _activeAccountId; + // Bumped at the start of every account switch/removal/activation so an + // in-flight fetch from the account being left can recognize it's stale + // (by comparing against the generation it captured at its own start) and + // discard its result instead of writing it into the now-active account's + // state. Every other controller's fetch methods check this too. + int _sessionGeneration = 0; + + bool _loginLockEnabled = false; + bool _lockAccountSwitching = false; + bool _lockHiddenFiles = false; + // Transient (never persisted) - starts locked whenever the app process + // starts, and re-locks on every backgrounding if a lock is configured; + // see didChangeAppLifecycleState. + bool _isUnlocked = false; + + static const _prefLoginLockEnabled = 'ui_login_lock_enabled'; + static const _prefLockAccountSwitching = 'ui_lock_account_switching'; + static const _prefLockHiddenFiles = 'ui_lock_hidden_files'; + + // Sibling controllers (Files/Photos/Favorites/Trash/Shares/Recent/Sync) + // register here instead of this controller holding forward references to + // them - it's constructed first, they depend on it. `cleared` fires + // wherever `_clearActiveContent()` used to run (about to switch/log out - + // reset your own state); `activated` fires only once credentials are + // actually verified (fetch your own data now). + final List _accountClearedListeners = []; + final List _accountActivatedListeners = []; + void addAccountClearedListener(VoidCallback cb) => + _accountClearedListeners.add(cb); + void addAccountActivatedListener(VoidCallback cb) => + _accountActivatedListeners.add(cb); + void _notifyAccountCleared() { + for (final cb in _accountClearedListeners) { + cb(); + } + } + + void _notifyAccountActivated() { + for (final cb in _accountActivatedListeners) { + cb(); + } + } + + SessionController() { + WidgetsBinding.instance.addObserver(this); + _init(); + } + + Future _init() async { + final prefs = await prefsFuture; + await accountStore.migrateLegacyIfNeeded(prefs); + _accounts = accountStore.loadAccounts(prefs); + _activeAccountId = accountStore.loadActiveAccountId(prefs); + await Future.wait([_loadPreferences(), _restoreSession()]); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Re-locks the app on backgrounding when login lock is set up - a + // one-time unlock at cold start would give the feature no real + // security value, since the realistic threat is someone else picking + // up an already-running, unlocked phone. + if (state == AppLifecycleState.paused && + _loginLockEnabled && + _isUnlocked) { + _isUnlocked = false; + notifyListeners(); + } + } + + // Getters + String get serverUrl => _serverUrl; + String get username => _username; + bool get isLoggedIn => _isLoggedIn; + bool get isLoading => _isLoading; + bool get isRestoringSession => _isRestoringSession; + String? get errorMessage => _errorMessage; + int get sessionGeneration => _sessionGeneration; + NextcloudService? get service => _service; + + LoginFlowStatus get loginFlowStatus => _loginFlowStatus; + Uri? get pendingLoginUrl => _pendingLoginUrl; + + List get accounts => List.unmodifiable(_accounts); + String? get activeAccountId => _activeAccountId; + SavedAccount? get activeAccount => + _accounts.where((a) => a.id == _activeAccountId).firstOrNull; + bool get isAddAccountFlow => _addAccountFlowActive; + + bool get loginLockEnabled => _loginLockEnabled; + bool get lockAccountSwitching => _lockAccountSwitching; + bool get lockHiddenFiles => _lockHiddenFiles; + bool get needsUnlock => _loginLockEnabled && !_isUnlocked; + + Future _restoreSession() async { + try { + final id = _activeAccountId; + if (id == null) return; + final account = _accounts.where((a) => a.id == id).firstOrNull; + if (account == null) return; + final password = await accountStore.readPassword(id); + if (password == null) return; + await _applyCredentialsForAccount(account, password); + } catch (e) { + debugPrint('[SessionController] Session restore failed: $e'); + } finally { + _isRestoringSession = false; + notifyListeners(); + } + } + + Future _loadPreferences() async { + try { + final prefs = await prefsFuture; + _loginLockEnabled = + prefs.getBool(_prefLoginLockEnabled) ?? _loginLockEnabled; + _lockAccountSwitching = + prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching; + _lockHiddenFiles = + prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles; + notifyListeners(); + } catch (e) { + debugPrint('[SessionController] Preference restore failed: $e'); + } + } + + /// Verifies [appPassword] for [account] and, on success, makes it the + /// live session, firing [_notifyAccountActivated] so every other + /// controller fetches its own data. The password is expected to already + /// be durably saved by the caller (either freshly, via + /// [_completeLoginFlow], or previously, since this is also how a saved + /// session is restored/switched to) - this method only writes to + /// [AccountStore] to drop a password that turns out to no longer work. + Future _applyCredentialsForAccount( + SavedAccount account, + String appPassword, + ) async { + final gen = _sessionGeneration; + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + _serverUrl = account.serverUrl; + _username = account.username; + _password = appPassword; + + _service = NextcloudService( + serverUrl: _serverUrl, + username: _username, + password: _password, + ); + + try { + final success = await _service!.testConnection(); + if (gen != _sessionGeneration) return false; + if (success) { + _isLoggedIn = true; + _isLoading = false; + notifyListeners(); + _notifyAccountActivated(); + return true; + } + } catch (e) { + if (gen != _sessionGeneration) return false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + // Only drop the stored password on an actual auth rejection (401) - + // testConnection also throws for network-level failures (DNS, + // timeout, unreachable host), and those are transient: deleting a + // still-valid password on a dropped connection would permanently + // log the account out with no way back in short of Login Flow v2 + // again, since activeAccountId is never cleared and switchAccount() + // no-ops when asked to "switch" to the account that's already + // (nominally) active. + if (_errorMessage?.contains('401') == true) { + await accountStore.deletePassword(account.id); + } + } + + if (gen != _sessionGeneration) return false; + _isLoggedIn = false; + _service = null; + _isLoading = false; + notifyListeners(); + return false; + } + + /// Starts Nextcloud Login Flow v2: asks the server for a one-time login + /// URL, then polls until the user authorizes (in LoginWebViewView, pushed + /// by LoginView once loginFlowStatus flips to awaitingBrowser below) and + /// the server hands back a scoped app password. The app never sees the + /// user's real password. [addAccount] only marks the flow as "add + /// another account" for [isAddAccountFlow] - the pushed Add Account + /// screen reads that to decide when to pop itself; this method's own + /// persistence behavior on success ([_completeLoginFlow]) is the same + /// either way (create-or-refresh the resulting account, then activate + /// it). + Future startLoginFlow( + String serverUrl, { + bool addAccount = false, + }) async { + _addAccountFlowActive = addAccount; + _loginFlowStatus = LoginFlowStatus.initiating; + _errorMessage = null; + notifyListeners(); + + try { + final init = await LoginFlowService.initiate(serverUrl); + _pendingLoginUrl = init.loginUrl; + _loginFlowStatus = LoginFlowStatus.awaitingBrowser; + notifyListeners(); + + _pollTimer?.cancel(); + _pollTimeoutTimer?.cancel(); + + _pollTimeoutTimer = Timer(const Duration(minutes: 10), () { + if (_loginFlowStatus == LoginFlowStatus.awaitingBrowser) { + cancelLoginFlow(errorMessage: 'Login timed out. Please try again.'); + } + }); + + _pollTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { + try { + final result = await LoginFlowService.poll( + init.pollEndpoint, + init.pollToken, + ); + if (result != null) { + timer.cancel(); + _pollTimeoutTimer?.cancel(); + _loginFlowStatus = LoginFlowStatus.idle; + _pendingLoginUrl = null; + await _completeLoginFlow(result); + _addAccountFlowActive = false; + } + } on http.ClientException catch (e) { + // A single dropped connection (e.g. the network briefly + // reconfiguring right as the login browser opens) used to abort + // the whole flow, forcing the user to start over even though + // they'd already granted access - just skip this tick and let + // the next scheduled poll (2s later) try again. The 10-minute + // timeout above is still the real ceiling. + debugPrint('[SessionController] Poll network hiccup, retrying: $e'); + } catch (e) { + timer.cancel(); + _pollTimeoutTimer?.cancel(); + cancelLoginFlow( + errorMessage: e.toString().replaceAll('Exception: ', ''), + ); + } + }); + } catch (e) { + _loginFlowStatus = LoginFlowStatus.error; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + notifyListeners(); + } + } + + void cancelLoginFlow({String? errorMessage}) { + _pollTimer?.cancel(); + _pollTimeoutTimer?.cancel(); + _pendingLoginUrl = null; + _addAccountFlowActive = false; + _loginFlowStatus = errorMessage != null + ? LoginFlowStatus.error + : LoginFlowStatus.idle; + _errorMessage = errorMessage; + notifyListeners(); + } + + /// Turns a completed Login Flow v2 result into a saved account (creating + /// it, or refreshing its password if it already existed - e.g. an + /// expired app password re-authenticated) and makes it the active + /// session. Used for both the first/only login and "add account". + Future _completeLoginFlow(LoginFlowResult result) async { + final serverUrl = LoginFlowService.normalizeServerUrl(result.serverUrl); + final id = SavedAccount.makeId(serverUrl, result.loginName); + final account = SavedAccount( + id: id, + serverUrl: serverUrl, + username: result.loginName, + ); + _accounts = [ + for (final a in _accounts) + if (a.id != id) a, + account, + ]; + + final prefs = await prefsFuture; + await accountStore.saveAccounts(prefs, _accounts); + await accountStore.writePassword(id, result.appPassword); + await _activateAccount(id); + } + + /// The shared engine behind switching accounts, falling back to another + /// account after removing the active one, and landing on the newly + /// created/refreshed account after a login flow completes: tears down + /// the outgoing account's live content (without ever setting + /// [isLoggedIn] false - see below), then loads the target account's + /// credentials. + /// + /// Non-goal, by design: no simultaneous multi-account state. This is a + /// full teardown-and-reload of the active session every time, exactly + /// like [logout] already did - just without touching any *other* saved + /// account's stored credentials/prefs. + Future _activateAccount(String accountId) async { + // Invalidates any fetch still in flight for the account being left, so + // a slow response can't land in the new account's state. + _sessionGeneration++; + // A pending "add account" flow can't stay pending through a manual + // switch/cycle - simplicity over blocking the gesture. + if (_loginFlowStatus != LoginFlowStatus.idle) cancelLoginFlow(); + + _notifyAccountCleared(); + // Not `_isLoggedIn = false` - that would bounce main.dart's root + // routing through LoginView mid-switch. Each tab already shows its own + // spinner from its own loading state, so this alone is enough to + // avoid flashing the outgoing account's stale content. + _isLoading = true; + notifyListeners(); + + _activeAccountId = accountId; + final prefs = await prefsFuture; + await accountStore.saveActiveAccountId(prefs, accountId); + notifyListeners(); + + final account = _accounts.where((a) => a.id == accountId).firstOrNull; + final password = account == null + ? null + : await accountStore.readPassword(accountId); + if (account == null || password == null) { + _isLoading = false; + _isLoggedIn = false; + notifyListeners(); + return; + } + + await _applyCredentialsForAccount(account, password); + } + + /// Switches to an already-saved account. No-op (returns true) if it's + /// already active and logged in; no-op (returns false) if unknown - but + /// if [accountId] is nominally "active" while [isLoggedIn] is false (a + /// session-restore that failed, e.g. transient network trouble at cold + /// start), this still retries rather than no-op, since that's exactly the + /// case LoginView's "Continue as" tile exists to recover from. Gated + /// behind login lock when [lockAccountSwitching] is on. Returns whether + /// the account ended up logged in, so callers (LoginView's saved-account + /// tile) can surface a failure - e.g. a stored app password that no + /// longer works and needs the account removed/re-added. + Future switchAccount(String accountId) async { + if (accountId == _activeAccountId && _isLoggedIn) return true; + if (!_accounts.any((a) => a.id == accountId)) return false; + if (!await passGate(_lockAccountSwitching, 'Unlock to switch accounts')) { + return false; + } + await _activateAccount(accountId); + return _isLoggedIn; + } + + Future _cycleAccount(int direction) async { + if (_accounts.length < 2) return null; + if (!await passGate(_lockAccountSwitching, 'Unlock to switch accounts')) { + return null; + } + final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId); + final targetIndex = currentIndex == -1 + ? 0 + : (currentIndex + direction) % _accounts.length; + final target = + _accounts[(targetIndex + _accounts.length) % _accounts.length]; + unawaited(_activateAccount(target.id)); + return target; + } + + /// Cycles to the next/previous saved account (by the order they were + /// added) - used by the avatar's swipe-up/down quick-switch gesture. + Future cycleToNextAccount() => _cycleAccount(1); + Future cycleToPreviousAccount() => _cycleAccount(-1); + + /// Removes a saved account entirely: its stored password, its + /// namespaced prefs, and its entry in the saved-accounts list. If it was + /// the active account, falls back to another saved account, or - if none + /// remain - deactivates the session (the only path here that sets + /// [isLoggedIn] false; unlike [logout], there's nothing left to keep). + Future removeAccount(String accountId) async { + final index = _accounts.indexWhere((a) => a.id == accountId); + if (index == -1) return; + final wasActive = accountId == _activeAccountId; + + _accounts = [..._accounts]..removeAt(index); + final prefs = await prefsFuture; + await accountStore.saveAccounts(prefs, _accounts); + await accountStore.deletePassword(accountId); + for (final key in AccountStore.perAccountPrefKeys) { + await prefs.remove(accountStore.accountPrefKey(accountId, key)); + } + + if (!wasActive) { + notifyListeners(); + return; + } + + if (_accounts.isEmpty) { + await _deactivateSession(); + return; + } + + await _activateAccount(_accounts.first.id); + } + + /// Clears the active session pointer and tears down its content, without + /// touching any saved account's own data - shared by [logout] (which + /// deliberately keeps the account around to resume later, no + /// re-authentication needed) and [removeAccount]'s "nothing left to fall + /// back to" branch (where the account's data has already been deleted by + /// the time this runs). + Future _deactivateSession() async { + _sessionGeneration++; + _notifyAccountCleared(); + _isLoggedIn = false; + _serverUrl = ''; + _username = ''; + _password = ''; + _activeAccountId = null; + final prefs = await prefsFuture; + await accountStore.saveActiveAccountId(prefs, null); + notifyListeners(); + unawaited(SyncService.cancel()); + } + + /// Ends the active session but keeps this account saved - unlike + /// [removeAccount], nothing is deleted (password, prefs, its entry in + /// [accounts] all remain), so it's available to resume with a single tap + /// from the login screen's saved-accounts list, no Login Flow v2 needed. + /// Always lands on the login screen even if other accounts are saved - + /// deliberately not the same as switching to one of them. + Future logout() async { + if (_activeAccountId == null) return; + await _deactivateSession(); + } + + /// Confirms the device can do local auth and prompts once to enable login + /// lock. Returns false (leaving lock disabled) if the device has no + /// biometric/PIN capability or the user cancels/fails the confirmation. + Future setupLoginLock() async { + if (!await AppLockService.isDeviceSupported()) return false; + final confirmed = await AppLockService.authenticate( + 'Confirm to turn on login lock', + ); + if (!confirmed) return false; + _loginLockEnabled = true; + // Already just authenticated - don't immediately re-prompt behind it. + _isUnlocked = true; + notifyListeners(); + prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, true)); + return true; + } + + /// Turns login lock off, along with both of its sub-toggles (meaningless + /// once the base lock is gone). Requires a successful auth first, same as + /// turning it on - otherwise anyone with momentary access to an unlocked + /// phone could just switch it off. + Future disableLoginLock() async { + if (!_loginLockEnabled) return true; + final confirmed = await AppLockService.authenticate( + 'Confirm to turn off login lock', + ); + if (!confirmed) return false; + _loginLockEnabled = false; + _lockAccountSwitching = false; + _lockHiddenFiles = false; + _isUnlocked = false; + notifyListeners(); + prefsFuture.then((p) { + p.setBool(_prefLoginLockEnabled, false); + p.setBool(_prefLockAccountSwitching, false); + p.setBool(_prefLockHiddenFiles, false); + }); + return true; + } + + void setLockAccountSwitching(bool value) { + if (!_loginLockEnabled) return; + _lockAccountSwitching = value; + notifyListeners(); + prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value)); + } + + void setLockHiddenFiles(bool value) { + if (!_loginLockEnabled) return; + _lockHiddenFiles = value; + notifyListeners(); + prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value)); + } + + /// Called by the lock screen. Returns whether it actually unlocked. + Future attemptUnlock() async { + final success = await AppLockService.authenticate('Unlock Noo'); + if (success) { + _isUnlocked = true; + notifyListeners(); + } + return success; + } + + /// Prompts for auth if [gate] is on and login lock is configured; + /// returns true immediately (no prompt) otherwise. Shared by the + /// account-switching gate above and [FilesController]'s/`PhotosController`'s + /// hidden-files gates. + Future passGate(bool gate, String reason) async { + if (!_loginLockEnabled || !gate) return true; + return AppLockService.authenticate(reason); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _pollTimer?.cancel(); + _pollTimeoutTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart new file mode 100644 index 0000000..f6c12ff --- /dev/null +++ b/lib/providers/settings_controller.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/app_tab.dart'; +import '../theme/app_theme.dart'; + +/// What swiping a Files list-view item left/right does, user-configurable +/// in Settings. +enum SwipeAction { none, favorite, delete, share } + +/// Thumb/track presets for the video player's seek bar, matching the four +/// combinations offered by other Material You media players: a Material 3 +/// slider-style thumb ([classic]), an animated travelling wave with a round +/// thumb ([wavy]), a thin flat bar with no distinct thumb ([slim]), and an +/// animated wave with a tick-mark thumb ([squiggly]). +enum MediaProgressBarStyle { classic, wavy, slim, squiggly } + +/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab +/// configuration, swipe actions. Independent of login state - these mean +/// the same thing whether any account is signed in or not, unlike +/// [FilesController]'s/etc. per-account display prefs. Split out of the +/// former single `ServerProvider` god object. +class SettingsController extends ChangeNotifier { + static const _prefThemeMode = 'ui_theme_mode'; + static const _prefUseDynamicColor = 'ui_use_dynamic_color'; + static const _prefSeedColor = 'ui_seed_color'; + static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity'; + static const _prefBottomBarBlur = 'ui_bottom_bar_blur'; + static const _prefTabOrder = 'ui_tab_order'; + static const _prefHiddenTabs = 'ui_hidden_tabs'; + static const _prefDefaultTab = 'ui_default_tab'; + static const _prefSwipeLeftAction = 'ui_swipe_left_action'; + static const _prefSwipeRightAction = 'ui_swipe_right_action'; + static const _prefAmoledDark = 'ui_amoled_dark'; + static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; + static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; + + final Future _prefsFuture = + SharedPreferences.getInstance(); + + Color _seedColor = AppTheme.defaultNextcloudBlue; + ThemeMode _themeMode = ThemeMode.system; + bool _useDynamicColor = true; + bool _amoledDark = false; + MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; + + double _bottomBarOpacity = 0.55; + double _bottomBarBlur = 28; + bool _tapTabToScrollTop = true; + + List _tabOrder = AppTab.values.toList(); + Set _hiddenTabs = {}; + AppTab _defaultTab = AppTab.files; + + SwipeAction _swipeLeftAction = SwipeAction.delete; + SwipeAction _swipeRightAction = SwipeAction.favorite; + + // A one-shot request for the shell to switch its active bottom-nav tab + // (e.g. a search result landing on Files) - consumed and cleared by + // MainShellView the next time it builds, not a persisted preference. + AppTab? _requestedTab; + AppTab? get requestedTab => _requestedTab; + + /// Asks the shell to switch its active bottom-nav tab to [tab] - e.g. so + /// tapping a search result lands the user on the Files tab even if they + /// opened search from somewhere else. + void requestTab(AppTab tab) { + _requestedTab = tab; + notifyListeners(); + } + + /// Called by the shell once it's consumed [requestedTab]. + void consumeRequestedTab() { + _requestedTab = null; + } + + SettingsController() { + _load(); + } + + Color get seedColor => _seedColor; + ThemeMode get themeMode => _themeMode; + bool get useDynamicColor => _useDynamicColor; + bool get amoledDark => _amoledDark; + MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; + double get bottomBarOpacity => _bottomBarOpacity; + double get bottomBarBlur => _bottomBarBlur; + bool get tapTabToScrollTop => _tapTabToScrollTop; + + /// Every tab in the user's configured order, including hidden ones — used + /// by the reorder/visibility settings UI. + List get tabOrder => _tabOrder; + Set get hiddenTabs => _hiddenTabs; + AppTab get defaultTab => _defaultTab; + + /// The tabs the bottom nav bar should actually show, in order. + List get visibleTabs => + _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); + + SwipeAction get swipeLeftAction => _swipeLeftAction; + SwipeAction get swipeRightAction => _swipeRightAction; + + Future _load() async { + try { + final prefs = await _prefsFuture; + + final themeModeName = prefs.getString(_prefThemeMode); + if (themeModeName != null) { + _themeMode = ThemeMode.values.firstWhere( + (m) => m.name == themeModeName, + orElse: () => ThemeMode.system, + ); + } + _useDynamicColor = + prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; + _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; + final progressBarStyleName = prefs.getString(_prefMediaProgressBarStyle); + if (progressBarStyleName != null) { + _mediaProgressBarStyle = MediaProgressBarStyle.values.firstWhere( + (s) => s.name == progressBarStyleName, + orElse: () => _mediaProgressBarStyle, + ); + } + final seedColorValue = prefs.getInt(_prefSeedColor); + if (seedColorValue != null) _seedColor = Color(seedColorValue); + _bottomBarOpacity = + prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity; + _bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur; + _tapTabToScrollTop = + prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop; + + final savedOrderNames = prefs.getStringList(_prefTabOrder); + if (savedOrderNames != null) { + final order = []; + for (final name in savedOrderNames) { + final match = AppTab.values.where((t) => t.name == name).firstOrNull; + if (match != null) order.add(match); + } + // Forward-compat: a tab added in a later app update won't be in an + // older saved order yet, so append anything missing. + for (final tab in AppTab.values) { + if (!order.contains(tab)) order.add(tab); + } + _tabOrder = order; + } + + final savedHiddenNames = prefs.getStringList(_prefHiddenTabs); + if (savedHiddenNames != null) { + _hiddenTabs = savedHiddenNames + .map( + (name) => AppTab.values.where((t) => t.name == name).firstOrNull, + ) + .whereType() + .toSet(); + // Never let every tab end up hidden. + if (_hiddenTabs.length >= AppTab.values.length) { + _hiddenTabs = {}; + } + } + + final savedDefaultName = prefs.getString(_prefDefaultTab); + if (savedDefaultName != null) { + final match = AppTab.values + .where((t) => t.name == savedDefaultName) + .firstOrNull; + if (match != null) _defaultTab = match; + } + if (_hiddenTabs.contains(_defaultTab)) { + _defaultTab = _tabOrder.firstWhere( + (t) => !_hiddenTabs.contains(t), + orElse: () => _tabOrder.first, + ); + } + _enforceMaxVisibleTabs(); + + final swipeLeftName = prefs.getString(_prefSwipeLeftAction); + if (swipeLeftName != null) { + _swipeLeftAction = SwipeAction.values.firstWhere( + (a) => a.name == swipeLeftName, + orElse: () => _swipeLeftAction, + ); + } + final swipeRightName = prefs.getString(_prefSwipeRightAction); + if (swipeRightName != null) { + _swipeRightAction = SwipeAction.values.firstWhere( + (a) => a.name == swipeRightName, + orElse: () => _swipeRightAction, + ); + } + + notifyListeners(); + } catch (e) { + debugPrint('[SettingsController] Preference restore failed: $e'); + } + } + + void setSeedColor(Color color) { + _seedColor = color; + _useDynamicColor = false; + notifyListeners(); + _prefsFuture.then((p) { + p.setInt(_prefSeedColor, color.toARGB32()); + p.setBool(_prefUseDynamicColor, false); + }); + } + + void setUseDynamicColor(bool value) { + _useDynamicColor = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefUseDynamicColor, value)); + } + + void setAmoledDark(bool value) { + _amoledDark = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefAmoledDark, value)); + } + + void setMediaProgressBarStyle(MediaProgressBarStyle style) { + _mediaProgressBarStyle = style; + notifyListeners(); + _prefsFuture.then( + (p) => p.setString(_prefMediaProgressBarStyle, style.name), + ); + } + + void setBottomBarOpacity(double value) { + _bottomBarOpacity = value; + notifyListeners(); + _prefsFuture.then((p) => p.setDouble(_prefBottomBarOpacity, value)); + } + + void setBottomBarBlur(double value) { + _bottomBarBlur = value; + notifyListeners(); + _prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value)); + } + + void setTapTabToScrollTop(bool value) { + _tapTabToScrollTop = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value)); + } + + void setThemeMode(ThemeMode mode) { + _themeMode = mode; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); + } + + void setTabOrder(List order) { + _tabOrder = order; + notifyListeners(); + _prefsFuture.then( + (p) => p.setStringList(_prefTabOrder, order.map((t) => t.name).toList()), + ); + } + + /// Shows or hides a tab in the bottom nav bar. Refuses to hide the last + /// remaining visible tab. If the tab being hidden is the current default, + /// the default falls back to the next visible tab. + /// Returns null on success, or a user-facing reason the change was + /// refused (hiding the last visible tab, or showing a 6th). + String? setTabHidden(AppTab tab, bool hidden) { + if (hidden) { + final remaining = _tabOrder + .where((t) => t != tab && !_hiddenTabs.contains(t)) + .length; + if (remaining == 0) return 'At least one tab must stay visible'; + _hiddenTabs = {..._hiddenTabs, tab}; + if (_defaultTab == tab) { + _defaultTab = _tabOrder.firstWhere((t) => !_hiddenTabs.contains(t)); + _prefsFuture.then( + (p) => p.setString(_prefDefaultTab, _defaultTab.name), + ); + } + } else { + final currentlyVisible = _tabOrder + .where((t) => !_hiddenTabs.contains(t)) + .length; + if (currentlyVisible >= maxVisibleTabs) { + return 'You can only show up to $maxVisibleTabs tabs at once'; + } + _hiddenTabs = {..._hiddenTabs}..remove(tab); + } + notifyListeners(); + _prefsFuture.then( + (p) => p.setStringList( + _prefHiddenTabs, + _hiddenTabs.map((t) => t.name).toList(), + ), + ); + return null; + } + + /// If more than [maxVisibleTabs] end up visible (e.g. a fresh install, or + /// an existing saved config from before a new tab was added to the app), + /// hide the overflow automatically rather than exceeding the cap. + void _enforceMaxVisibleTabs() { + final visible = _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); + if (visible.length <= maxVisibleTabs) return; + _hiddenTabs = {..._hiddenTabs, ...visible.sublist(maxVisibleTabs)}; + if (_hiddenTabs.contains(_defaultTab)) { + _defaultTab = _tabOrder.firstWhere((t) => !_hiddenTabs.contains(t)); + } + } + + void setDefaultTab(AppTab tab) { + if (_hiddenTabs.contains(tab) || _defaultTab == tab) return; + _defaultTab = tab; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefDefaultTab, tab.name)); + } + + void setSwipeLeftAction(SwipeAction action) { + if (_swipeLeftAction == action) return; + _swipeLeftAction = action; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefSwipeLeftAction, action.name)); + } + + void setSwipeRightAction(SwipeAction action) { + if (_swipeRightAction == action) return; + _swipeRightAction = action; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name)); + } +} diff --git a/lib/providers/shares_controller.dart b/lib/providers/shares_controller.dart new file mode 100644 index 0000000..fe6c5aa --- /dev/null +++ b/lib/providers/shares_controller.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../models/nextcloud_share.dart'; +import 'session_controller.dart'; + +/// The Shares tab. Kept separate from Files' own loading/error state, +/// likewise. +class SharesController extends ChangeNotifier { + final SessionController session; + + List _shares = []; + bool _isLoading = false; + String? _errorMessage; + bool _sharedWithMe = false; + + SharesController(this.session) { + session.addAccountClearedListener(_onAccountCleared); + session.addAccountActivatedListener(_onAccountActivated); + } + + List get shares => _shares; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + bool get sharedWithMe => _sharedWithMe; + + void _onAccountCleared() { + _shares = []; + _isLoading = false; + _errorMessage = null; + _sharedWithMe = false; + notifyListeners(); + } + + void _onAccountActivated() { + unawaited(fetchAll()); + } + + Future fetchAll() async { + final service = session.service; + if (!session.isLoggedIn || service == null) return; + final gen = session.sessionGeneration; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final shares = await service.fetchShares(sharedWithMe: _sharedWithMe); + if (gen != session.sessionGeneration) return; + _shares = shares; + } catch (e) { + if (gen != session.sessionGeneration) return; + debugPrint('[SharesController] Error fetching shares: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + if (gen == session.sessionGeneration) { + _isLoading = false; + notifyListeners(); + } + } + } + + /// Switches between "shared by me" and "shared with me" and refetches. + void setSharedWithMe(bool value) { + if (_sharedWithMe == value) return; + _sharedWithMe = value; + notifyListeners(); + fetchAll(); + } + + Future deleteShare(NextcloudShare share) async { + final service = session.service; + if (service == null) return false; + final success = await service.deleteShare(share.id); + if (success) { + _shares = _shares.where((s) => s.id != share.id).toList(); + notifyListeners(); + } + return success; + } +} diff --git a/lib/providers/sync_status_controller.dart b/lib/providers/sync_status_controller.dart new file mode 100644 index 0000000..d574125 --- /dev/null +++ b/lib/providers/sync_status_controller.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import '../models/nextcloud_item.dart'; +import '../models/sync_status.dart'; +import '../services/sync_service.dart'; +import 'session_controller.dart'; + +/// Device-sync state and settings: which remote paths (files or folders, +/// per account) get mirrored locally by the native `SyncWorker`, whether +/// its periodic background runs are allowed on cellular, and live status +/// pushed from `SyncStatusBus.kt` (syncing-now/per-file-syncing/synced/ +/// conflicts) - see `server.md`'s "Device sync" section. Split out of the +/// former single `ServerProvider` god object specifically because this +/// state used to reach directly into Files/Photos/Favorites item lists +/// (`syncStatusFor`) despite living in an unrelated domain; now it's a +/// second, focused provider those views read alongside their own tab +/// controller. +class SyncStatusController extends ChangeNotifier { + final SessionController session; + + static const _prefSyncedPaths = 'ui_synced_folders'; + static const _prefSyncEverything = 'ui_sync_everything'; + static const _prefSyncOnCellular = 'ui_sync_on_cellular'; + + List _syncedPaths = []; + bool _syncEverything = false; + bool _syncOnCellular = false; + + bool _isSyncingNow = false; + Set _syncingFileIds = {}; + Set _syncedFileIds = {}; + List _syncConflicts = []; + StreamSubscription? _statusSub; + + SyncStatusController(this.session) { + session.addAccountClearedListener(_onAccountCleared); + session.addAccountActivatedListener(_onAccountActivated); + _statusSub = SyncService.statusStream.listen(_applySnapshot); + _loadGlobalPrefs(); + } + + List get syncedPaths => List.unmodifiable(_syncedPaths); + bool get syncEverything => _syncEverything; + bool get syncOnCellular => _syncOnCellular; + + bool get isSyncingNow => _isSyncingNow; + List get syncConflicts => List.unmodifiable(_syncConflicts); + + SyncHeaderStatus get syncHeaderStatus { + if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert; + if (_isSyncingNow) return SyncHeaderStatus.syncing; + if (!_syncEverything && _syncedPaths.isEmpty) return SyncHeaderStatus.off; + return SyncHeaderStatus.done; + } + + SyncItemStatus syncStatusFor(NextcloudItem item) { + if (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing; + if (_syncConflicts.any((c) => c.fileId == item.id)) { + return SyncItemStatus.conflict; + } + if (_syncedFileIds.contains(item.id)) return SyncItemStatus.synced; + return SyncItemStatus.none; + } + + Future _loadGlobalPrefs() async { + final prefs = await session.prefsFuture; + _syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular; + notifyListeners(); + } + + void _onAccountCleared() { + _syncedPaths = []; + _syncEverything = false; + _isSyncingNow = false; + _syncingFileIds = {}; + _syncedFileIds = {}; + _syncConflicts = []; + notifyListeners(); + } + + Future _onAccountActivated() async { + final id = session.activeAccountId; + if (id != null) { + final prefs = await session.prefsFuture; + String k(String base) => session.accountStore.accountPrefKey(id, base); + final pathsJson = prefs.getString(k(_prefSyncedPaths)); + if (pathsJson != null) { + try { + _syncedPaths = (jsonDecode(pathsJson) as List).cast(); + } catch (e) { + debugPrint('[SyncStatusController] Synced paths restore failed: $e'); + _syncedPaths = []; + } + } else { + _syncedPaths = []; + } + _syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false; + notifyListeners(); + } + unawaited(SyncService.reschedule(session, this)); + unawaited(SyncService.getStatus().then(_applySnapshot)); + } + + void _applySnapshot(SyncStatusSnapshot snapshot) { + if (snapshot.accountId != null && + snapshot.accountId != session.activeAccountId) { + return; + } + _isSyncingNow = snapshot.syncing; + _syncingFileIds = snapshot.syncingFileIds; + _syncedFileIds = snapshot.syncedFileIds; + _syncConflicts = snapshot.conflicts; + notifyListeners(); + } + + void _persistSyncedPaths() { + final id = session.activeAccountId; + if (id == null) return; + session.prefsFuture.then( + (p) => p.setString( + session.accountStore.accountPrefKey(id, _prefSyncedPaths), + jsonEncode(_syncedPaths), + ), + ); + } + + bool isPathSynced(String path) => _syncedPaths.contains(path); + + void addSyncedPath(String path) { + if (_syncedPaths.contains(path)) return; + _syncedPaths = [..._syncedPaths, path]; + notifyListeners(); + _persistSyncedPaths(); + unawaited(SyncService.reschedule(session, this)); + } + + void removeSyncedPath(String path) { + if (!_syncedPaths.contains(path)) return; + _syncedPaths = _syncedPaths.where((f) => f != path).toList(); + notifyListeners(); + _persistSyncedPaths(); + unawaited(SyncService.reschedule(session, this)); + } + + void setSyncEverything(bool value) { + if (_syncEverything == value) return; + _syncEverything = value; + notifyListeners(); + final id = session.activeAccountId; + if (id != null) { + session.prefsFuture.then( + (p) => p.setBool( + session.accountStore.accountPrefKey(id, _prefSyncEverything), + value, + ), + ); + } + unawaited(SyncService.reschedule(session, this)); + } + + void setSyncOnCellular(bool value) { + if (_syncOnCellular == value) return; + _syncOnCellular = value; + notifyListeners(); + session.prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value)); + unawaited(SyncService.reschedule(session, this)); + } + + /// In-app conflict resolution (the sync header's "Keep local"/"Use + /// server" buttons) - see `SyncService.resolveConflict`'s doc comment + /// for why this shares the exact same native path the notification + /// actions use. + Future resolveSyncConflict( + SyncConflictInfo conflict, { + required bool useLocal, + }) { + return SyncService.resolveConflict( + session, + conflict, + useLocal ? 'local' : 'server', + ); + } + + /// The local mirror path for [item] if it falls under a synced folder + /// *and* has actually been synced down already - a pure function of the + /// remote path once a folder's marked synced (mirrors + /// `SyncEngine.kt#syncRoot`'s `/sync//...` + /// layout exactly), so this never needs to read the native sync-state + /// SharedPreferences from Dart. Callers (Share/Download) use this to + /// skip a fresh WebDAV fetch when a local copy already exists. + Future localSyncedFilePath(NextcloudItem item) async { + final id = session.activeAccountId; + if (id == null) return null; + final itemPath = item.path.endsWith('/') + ? item.path.substring(0, item.path.length - 1) + : item.path; + final isSynced = + _syncEverything || + _syncedPaths.any((folder) { + final f = folder.endsWith('/') + ? folder.substring(0, folder.length - 1) + : folder; + return itemPath == f || itemPath.startsWith('$f/'); + }); + if (!isSynced) return null; + return SyncService.localSyncedFilePath(id, item.path); + } + + @override + void dispose() { + _statusSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/trash_controller.dart b/lib/providers/trash_controller.dart new file mode 100644 index 0000000..8be25d5 --- /dev/null +++ b/lib/providers/trash_controller.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../models/nextcloud_item.dart'; +import 'session_controller.dart'; + +/// The Trash tab. Kept separate from Files' own loading/error state so a +/// trash-fetch failure can't bleed a stale error into Files. +class TrashController extends ChangeNotifier { + final SessionController session; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + TrashController(this.session) { + session.addAccountClearedListener(_onAccountCleared); + session.addAccountActivatedListener(_onAccountActivated); + } + + List get items => _items; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + + void _onAccountCleared() { + _items = []; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } + + void _onAccountActivated() { + unawaited(fetchAll()); + } + + Future fetchAll() async { + final service = session.service; + if (!session.isLoggedIn || service == null) return; + final gen = session.sessionGeneration; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final trash = await service.fetchTrash(); + if (gen != session.sessionGeneration) return; + _items = trash; + } catch (e) { + if (gen != session.sessionGeneration) return; + debugPrint('[TrashController] Error fetching trash: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + if (gen == session.sessionGeneration) { + _isLoading = false; + notifyListeners(); + } + } + } + + /// Restores a trashed item (identified by [NextcloudItem.path], the + /// trash-relative name) back to where it was deleted from. + Future restore(NextcloudItem item) async { + final service = session.service; + if (service == null) return false; + final success = await service.restoreTrashItem(item.path); + if (success) { + _items = _items.where((i) => i.id != item.id).toList(); + notifyListeners(); + } + return success; + } + + /// Permanently deletes a trashed item. Cannot be undone. + Future deleteForever(NextcloudItem item) async { + final service = session.service; + if (service == null) return false; + final success = await service.deleteTrashItemForever(item.path); + if (success) { + _items = _items.where((i) => i.id != item.id).toList(); + notifyListeners(); + } + return success; + } +} diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 88bdb63..146a70c 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/session_controller.dart'; +import 'native_channel.dart'; /// Hands a download off to `DownloadService.kt`'s Android foreground /// service - the download counterpart of `UploadService`/ @@ -20,15 +21,10 @@ class DownloadService { /// progress/completion/cancellation through its own notification, not /// back to the app. static Future startDownload( - ServerProvider provider, + SessionController session, List items, ) async { - final service = provider.service; - final authHeader = service?.authHeaders['Authorization']; - if (service == null || authHeader == null) { - throw Exception('Not logged in.'); - } - + final args = baseChannelArgs(session); final filesJson = jsonEncode( items .map( @@ -42,11 +38,6 @@ class DownloadService { .toList(), ); - await _channel.invokeMethod('startDownload', { - 'files': filesJson, - 'serverUrl': provider.serverUrl, - 'username': provider.username, - 'authHeader': authHeader, - }); + await _channel.invokeMethod('startDownload', {...args, 'files': filesJson}); } } diff --git a/lib/services/native_channel.dart b/lib/services/native_channel.dart new file mode 100644 index 0000000..ef12b69 --- /dev/null +++ b/lib/services/native_channel.dart @@ -0,0 +1,23 @@ +import '../providers/session_controller.dart'; + +/// The "get this account's auth header, or bail" guard every native- +/// MethodChannel service (`UploadService`/`DownloadService`/`SyncService`) +/// needs before handing a batch off to Kotlin - was independently repeated +/// in each of them. +String requireAuthHeader(SessionController session) { + final authHeader = session.service?.authHeaders['Authorization']; + if (session.service == null || authHeader == null) { + throw Exception('Not logged in.'); + } + return authHeader; +} + +/// The `{serverUrl, username, authHeader}` trio every one of those +/// services' `invokeMethod` argument maps includes. +Map baseChannelArgs(SessionController session) { + return { + 'serverUrl': session.serverUrl, + 'username': session.username, + 'authHeader': requireAuthHeader(session), + }; +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 626eff8..40e8dfc 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -1,7 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import '../models/sync_status.dart'; -import '../providers/server_provider.dart'; +import '../providers/session_controller.dart'; +import '../providers/sync_status_controller.dart'; /// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the /// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`. @@ -43,34 +47,38 @@ class SyncStatusSnapshot { /// Dart's job here is only to gather what the native side needs, push a /// fresh config whenever it changes, and listen for live status; the actual /// PROPFIND-walk/diff/GET/PUT engine runs entirely in Kotlin, independent -/// of the Flutter engine. +/// of the Flutter engine. Takes [SessionController]/[SyncStatusController] +/// directly rather than a single god-object provider, since those are the +/// only two domains this ever needs. class SyncService { static const _channel = MethodChannel('dev.ayushya.noo/sync_service'); static const _statusChannel = EventChannel( 'dev.ayushya.noo/sync_service/status', ); - /// Cancels (if [ServerProvider.syncedPaths] is now empty) or re-enqueues - /// the periodic sync job with fresh account/credentials/path-list/ - /// network-constraint data - call this any time one of those changes, - /// since a periodic `WorkRequest`'s input is fixed at enqueue time and - /// can only be updated by re-enqueueing. - static Future reschedule(ServerProvider provider) async { - final accountId = provider.activeAccountId; - final service = provider.service; - final authHeader = service?.authHeaders['Authorization']; - if (accountId == null || service == null || authHeader == null) { + /// Cancels (if [SyncStatusController.syncedPaths] is now empty) or + /// re-enqueues the periodic sync job with fresh account/credentials/ + /// path-list/network-constraint data - call this any time one of those + /// changes, since a periodic `WorkRequest`'s input is fixed at enqueue + /// time and can only be updated by re-enqueueing. + static Future reschedule( + SessionController session, + SyncStatusController sync, + ) async { + final accountId = session.activeAccountId; + final authHeader = session.service?.authHeaders['Authorization']; + if (accountId == null || authHeader == null) { return cancel(); } - final paths = provider.syncEverything ? ['/'] : provider.syncedPaths; + final paths = sync.syncEverything ? ['/'] : sync.syncedPaths; await _channel.invokeMethod('reschedule', { 'accountId': accountId, - 'serverUrl': provider.serverUrl, - 'username': provider.username, + 'serverUrl': session.serverUrl, + 'username': session.username, 'authHeader': authHeader, 'folders': jsonEncode(paths), - 'wifiOnly': !provider.syncOnCellular, + 'wifiOnly': !sync.syncOnCellular, }); } @@ -80,27 +88,29 @@ class SyncService { /// Runs a one-off sync pass immediately (Settings' "Sync now"), /// independent of the periodic schedule. - static Future syncNow(ServerProvider provider) async { - final accountId = provider.activeAccountId; - final service = provider.service; - final authHeader = service?.authHeaders['Authorization']; - if (accountId == null || service == null || authHeader == null) { + static Future syncNow( + SessionController session, + SyncStatusController sync, + ) async { + final accountId = session.activeAccountId; + final authHeader = session.service?.authHeaders['Authorization']; + if (accountId == null || authHeader == null) { throw Exception('Not logged in.'); } - final paths = provider.syncEverything ? ['/'] : provider.syncedPaths; + final paths = sync.syncEverything ? ['/'] : sync.syncedPaths; await _channel.invokeMethod('syncNow', { 'accountId': accountId, - 'serverUrl': provider.serverUrl, - 'username': provider.username, + 'serverUrl': session.serverUrl, + 'username': session.username, 'authHeader': authHeader, 'folders': jsonEncode(paths), }); } - /// One-shot status snapshot - used to seed [ServerProvider]'s state right - /// after login/account-switch, before the first [statusStream] event - /// arrives. + /// One-shot status snapshot - used to seed [SyncStatusController]'s state + /// right after login/account-switch, before the first [statusStream] + /// event arrives. static Future getStatus() async { final result = await _channel.invokeMethod>( 'getSyncStatus', @@ -121,19 +131,16 @@ class SyncService { /// enqueue path the notification actions use, just triggered from Dart /// instead of a `PendingIntent`. static Future resolveConflict( - ServerProvider provider, + SessionController session, SyncConflictInfo conflict, String resolution, ) async { - final service = provider.service; - final authHeader = service?.authHeaders['Authorization']; - if (service == null || authHeader == null) { - throw Exception('Not logged in.'); - } + final authHeader = session.service?.authHeaders['Authorization']; + if (authHeader == null) throw Exception('Not logged in.'); await _channel.invokeMethod('resolveConflict', { 'accountId': conflict.accountId, - 'serverUrl': provider.serverUrl, - 'username': provider.username, + 'serverUrl': session.serverUrl, + 'username': session.username, 'authHeader': authHeader, 'fileId': conflict.fileId, 'remotePath': conflict.remotePath, @@ -141,4 +148,22 @@ class SyncService { 'resolution': resolution, }); } + + /// The deterministic local mirror path for [remoteItemPath] under + /// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s + /// `/sync//...` layout exactly, so this + /// never needs to read native sync state. Returns null if nothing's + /// actually been synced there yet. + static Future localSyncedFilePath( + String accountId, + String remoteItemPath, + ) async { + final base = await getExternalStorageDirectory(); + if (base == null) return null; + final relPath = remoteItemPath.startsWith('/') + ? remoteItemPath.substring(1) + : remoteItemPath; + final file = File(p.join(base.path, 'sync', accountId, relPath)); + return file.existsSync() ? file.path : null; + } } diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index 07b4cd7..f047ce6 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:flutter/services.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/session_controller.dart'; +import 'native_channel.dart'; import 'share_intent_service.dart'; /// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android @@ -18,25 +20,21 @@ class UploadService { /// service owns the rest and reports progress/completion/cancellation /// through its own notification, not back to the app. static Future startUpload( - ServerProvider provider, - List files, + SessionController session, + FilesController files, + List sharedFiles, ) async { - final service = provider.service; - final authHeader = service?.authHeaders['Authorization']; - if (service == null || authHeader == null) { - throw Exception('Not logged in.'); - } - + final args = baseChannelArgs(session); final filesJson = jsonEncode( - files.map((f) => {'uri': f.uri, 'name': f.name, 'size': f.size}).toList(), + sharedFiles + .map((f) => {'uri': f.uri, 'name': f.name, 'size': f.size}) + .toList(), ); await _channel.invokeMethod('startUpload', { + ...args, 'files': filesJson, - 'serverUrl': provider.serverUrl, - 'username': provider.username, - 'authHeader': authHeader, - 'remoteFolder': provider.currentFolderPath, + 'remoteFolder': files.currentFolderPath, }); } } diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index b0ffb83..e4ff0fa 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; import '../models/saved_account.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; +import '../providers/sync_status_controller.dart'; import '../services/sync_service.dart'; import '../theme/app_theme.dart'; import '../widgets/frosted_glass_container.dart'; @@ -17,8 +20,10 @@ class AccountView extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); - final quota = provider.quota; + final session = context.watch(); + final settings = context.watch(); + final files = context.watch(); + final quota = files.quota; return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -46,7 +51,7 @@ class AccountView extends StatelessWidget { radius: 28, backgroundColor: colorScheme.primary, child: Text( - (quota?.userName ?? provider.username) + (quota?.userName ?? session.username) .substring(0, 1) .toUpperCase(), style: theme.textTheme.headlineSmall?.copyWith( @@ -61,14 +66,14 @@ class AccountView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - quota?.userName ?? provider.username, + quota?.userName ?? session.username, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( - quota?.email ?? provider.username, + quota?.email ?? session.username, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -119,8 +124,8 @@ class AccountView extends StatelessWidget { children: [ Expanded( child: Text( - Uri.tryParse(provider.serverUrl)?.host ?? - provider.serverUrl, + Uri.tryParse(session.serverUrl)?.host ?? + session.serverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( @@ -185,7 +190,7 @@ class AccountView extends StatelessWidget { icon: const Icon(Icons.refresh_rounded), tooltip: 'Refresh cached data', onPressed: () async { - await provider.refreshData(); + await files.refreshData(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -200,14 +205,14 @@ class AccountView extends StatelessWidget { icon: const Icon(Icons.logout_rounded), tooltip: 'Logout', color: Colors.orange.shade700, - onPressed: () => _handleLogout(context, provider), + onPressed: () => _handleLogout(context, session), ), IconButton( icon: const Icon(Icons.delete_outline_rounded), tooltip: 'Remove account', color: colorScheme.error, onPressed: () => - _confirmRemoveActive(context, provider), + _confirmRemoveActive(context, session), ), ], ), @@ -271,7 +276,7 @@ class AccountView extends StatelessWidget { ), const SizedBox(height: 4), Text( - provider.useDynamicColor + settings.useDynamicColor ? 'Matching your wallpaper' : 'Custom color', style: theme.textTheme.bodySmall?.copyWith( @@ -283,8 +288,8 @@ class AccountView extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _AccentSwatch( - isSelected: provider.useDynamicColor, - onTap: () => provider.setUseDynamicColor(true), + isSelected: settings.useDynamicColor, + onTap: () => settings.setUseDynamicColor(true), borderColor: colorScheme.onSurface, background: colorScheme.surfaceContainerHighest, child: Icon( @@ -295,11 +300,11 @@ class AccountView extends StatelessWidget { ), ...AppTheme.seedColors.map((color) { final isSelected = - !provider.useDynamicColor && - provider.seedColor == color; + !settings.useDynamicColor && + settings.seedColor == color; return _AccentSwatch( isSelected: isSelected, - onTap: () => provider.setSeedColor(color), + onTap: () => settings.setSeedColor(color), borderColor: colorScheme.onSurface, background: color, child: isSelected @@ -343,9 +348,9 @@ class AccountView extends StatelessWidget { label: Text('Dark'), ), ], - selected: {provider.themeMode}, + selected: {settings.themeMode}, onSelectionChanged: (set) => - provider.setThemeMode(set.first), + settings.setThemeMode(set.first), ), ), const SizedBox(height: 8), @@ -355,8 +360,8 @@ class AccountView extends StatelessWidget { subtitle: const Text( 'Use pure black backgrounds in dark mode', ), - value: provider.amoledDark, - onChanged: provider.setAmoledDark, + value: settings.amoledDark, + onChanged: settings.setAmoledDark, ), ], ), @@ -380,8 +385,8 @@ class AccountView extends StatelessWidget { subtitle: const Text( 'Tapping the current bottom bar tab scrolls its list back to the top', ), - value: provider.tapTabToScrollTop, - onChanged: provider.setTapTabToScrollTop, + value: settings.tapTabToScrollTop, + onChanged: settings.setTapTabToScrollTop, ), ), const SizedBox(height: 24), @@ -462,24 +467,24 @@ class AccountView extends StatelessWidget { ); } - /// Logout keeps the account saved (see [ServerProvider.logout]) so it's - /// harmless/reversible from a one-tap resume on the login screen - + /// Logout keeps the account saved (see [SessionController.logout]) so + /// it's harmless/reversible from a one-tap resume on the login screen - /// doesn't need a confirmation dialog the way [_confirmRemoveActive] does. Future _handleLogout( BuildContext context, - ServerProvider provider, + SessionController session, ) async { final navigator = Navigator.of(context); - await provider.logout(); + await session.logout(); navigator.popUntil((route) => route.isFirst); } Future _confirmRemoveActive( BuildContext context, - ServerProvider provider, + SessionController session, ) async { - final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl; - final hasOtherAccounts = provider.accounts.length > 1; + final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl; + final hasOtherAccounts = session.accounts.length > 1; final confirmed = await showDialog( context: context, builder: (dialogContext) { @@ -487,8 +492,8 @@ class AccountView extends StatelessWidget { title: const Text('Remove Account'), content: Text( hasOtherAccounts - ? 'Remove ${provider.username} ($host)? Another saved account will become active.' - : 'Remove ${provider.username} ($host)? You can add it again later.', + ? 'Remove ${session.username} ($host)? Another saved account will become active.' + : 'Remove ${session.username} ($host)? You can add it again later.', ), actions: [ TextButton( @@ -507,15 +512,15 @@ class AccountView extends StatelessWidget { }, ); if (confirmed != true) return; - final id = provider.activeAccountId; + final id = session.activeAccountId; if (id == null) return; if (!context.mounted) return; final navigator = Navigator.of(context); - await provider.removeAccount(id); + await session.removeAccount(id); // Only pop back to the root route if that was the last account and the // session actually ended - if it fell back to another saved account, // Settings just keeps showing (now for that account) instead. - if (!provider.isLoggedIn) { + if (!session.isLoggedIn) { navigator.popUntil((route) => route.isFirst); } } @@ -529,10 +534,10 @@ class _AccountsCard extends StatelessWidget { Future _confirmRemove( BuildContext context, - ServerProvider provider, + SessionController session, SavedAccount account, ) async { - final isActive = account.id == provider.activeAccountId; + final isActive = account.id == session.activeAccountId; final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; final confirmed = await showDialog( context: context, @@ -540,7 +545,7 @@ class _AccountsCard extends StatelessWidget { return AlertDialog( title: const Text('Remove Account'), content: Text( - isActive && provider.accounts.length > 1 + isActive && session.accounts.length > 1 ? 'Remove ${account.username} ($host)? Another saved account will become active.' : 'Remove ${account.username} ($host)? You can add it again later.', ), @@ -563,13 +568,13 @@ class _AccountsCard extends StatelessWidget { if (confirmed == true) { if (!context.mounted) return; final navigator = Navigator.of(context); - await provider.removeAccount(account.id); + await session.removeAccount(account.id); // Only when this was the last saved account does isLoggedIn drop to // false and main.dart swap the root route to LoginView underneath - // pop back to it then, rather than leaving Settings stranded on top. // Removing a non-active account, or falling back to another one, // both keep the user logged in, so Settings should just stay put. - if (!provider.isLoggedIn) { + if (!session.isLoggedIn) { navigator.popUntil((route) => route.isFirst); } } @@ -577,8 +582,8 @@ class _AccountsCard extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); - final accounts = provider.accounts; + final session = context.watch(); + final accounts = session.accounts; return Card( child: Column( @@ -586,11 +591,11 @@ class _AccountsCard extends StatelessWidget { for (final account in accounts) ...[ _AccountRow( account: account, - isActive: account.id == provider.activeAccountId, - onTap: account.id == provider.activeAccountId + isActive: account.id == session.activeAccountId, + onTap: account.id == session.activeAccountId ? null - : () => provider.switchAccount(account.id), - onRemove: () => _confirmRemove(context, provider, account), + : () => session.switchAccount(account.id), + onRemove: () => _confirmRemove(context, session, account), ), const Divider(height: 1, indent: 16, endIndent: 16), ], @@ -671,12 +676,12 @@ class _SecurityCard extends StatelessWidget { Future _handleLoginLockChanged( BuildContext context, - ServerProvider provider, + SessionController session, bool value, ) async { final success = value - ? await provider.setupLoginLock() - : await provider.disableLoginLock(); + ? await session.setupLoginLock() + : await session.disableLoginLock(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -693,7 +698,7 @@ class _SecurityCard extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); + final session = context.watch(); return Card( child: Column( @@ -704,18 +709,18 @@ class _SecurityCard extends StatelessWidget { subtitle: const Text( "Require this device's PIN or biometric to open Noo", ), - value: provider.loginLockEnabled, + value: session.loginLockEnabled, onChanged: (value) => - _handleLoginLockChanged(context, provider, value), + _handleLoginLockChanged(context, session, value), ), const Divider(height: 1, indent: 16, endIndent: 16), SwitchListTile( secondary: const Icon(Icons.swap_horiz_rounded), title: const Text('Lock account switching'), subtitle: const Text('Unlock to switch between saved accounts'), - value: provider.lockAccountSwitching, - onChanged: provider.loginLockEnabled - ? provider.setLockAccountSwitching + value: session.lockAccountSwitching, + onChanged: session.loginLockEnabled + ? session.setLockAccountSwitching : null, ), const Divider(height: 1, indent: 16, endIndent: 16), @@ -723,9 +728,9 @@ class _SecurityCard extends StatelessWidget { secondary: const Icon(Icons.visibility_off_rounded), title: const Text('Lock hidden files'), subtitle: const Text('Unlock to reveal hidden files and folders'), - value: provider.lockHiddenFiles, - onChanged: provider.loginLockEnabled - ? provider.setLockHiddenFiles + value: session.lockHiddenFiles, + onChanged: session.loginLockEnabled + ? session.setLockHiddenFiles : null, ), ], @@ -744,10 +749,13 @@ class _DeviceSyncCard extends StatefulWidget { class _DeviceSyncCardState extends State<_DeviceSyncCard> { bool _syncingNow = false; - Future _syncNow(ServerProvider provider) async { + Future _syncNow( + SessionController session, + SyncStatusController sync, + ) async { setState(() => _syncingNow = true); try { - await SyncService.syncNow(provider); + await SyncService.syncNow(session, sync); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -766,9 +774,10 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); - final folders = provider.syncedPaths; - final everything = provider.syncEverything; + final session = context.watch(); + final sync = context.watch(); + final folders = sync.syncedPaths; + final everything = sync.syncEverything; return Card( child: Column( @@ -781,7 +790,7 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { 'Mirror the whole account instead of picking folders', ), value: everything, - onChanged: provider.setSyncEverything, + onChanged: sync.setSyncEverything, ), const Divider(height: 1, indent: 16, endIndent: 16), if (everything) @@ -814,7 +823,7 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { trailing: IconButton( icon: const Icon(Icons.close_rounded), tooltip: 'Stop syncing', - onPressed: () => provider.removeSyncedPath(folder), + onPressed: () => sync.removeSyncedPath(folder), ), ), const Divider(height: 1, indent: 16, endIndent: 16), @@ -823,8 +832,8 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { 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, + value: sync.syncOnCellular, + onChanged: sync.setSyncOnCellular, ), const Divider(height: 1, indent: 16, endIndent: 16), ListTile( @@ -840,7 +849,7 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { : const Icon(Icons.sync_rounded), title: const Text('Sync now'), enabled: (everything || folders.isNotEmpty) && !_syncingNow, - onTap: () => _syncNow(provider), + onTap: () => _syncNow(session, sync), ), ], ), @@ -857,9 +866,9 @@ class _BottomBarAppearanceCard extends StatefulWidget { } class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { - // Mirrors the provider values locally so the slider thumb and the live + // Mirrors the controller values locally so the slider thumb and the live // preview track the drag gesture on every frame. Driving them straight off - // `provider.bottomBar*` instead would tie the slider's own responsiveness + // `settings.bottomBar*` instead would tie the slider's own responsiveness // to a full Provider-wide rebuild — including the expensive BackdropFilter // blur in the preview — which can't keep up with fast drag ticks and makes // the thumb appear stuck until the next unrelated rebuild. @@ -870,9 +879,9 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); - final opacity = _opacity ?? provider.bottomBarOpacity; - final blur = _blur ?? provider.bottomBarBlur; + final settings = context.watch(); + final opacity = _opacity ?? settings.bottomBarOpacity; + final blur = _blur ?? settings.bottomBarBlur; return Card( child: Padding( @@ -974,7 +983,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { min: 0.1, max: 1.0, onChanged: (value) => setState(() => _opacity = value), - onChangeEnd: provider.setBottomBarOpacity, + onChangeEnd: settings.setBottomBarOpacity, ), Row( @@ -994,7 +1003,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { min: 0, max: 40, onChanged: (value) => setState(() => _blur = value), - onChangeEnd: provider.setBottomBarBlur, + onChangeEnd: settings.setBottomBarBlur, ), ], ), @@ -1075,7 +1084,7 @@ class _SwipeActionsCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final provider = context.watch(); + final settings = context.watch(); return Card( child: Padding( @@ -1086,16 +1095,16 @@ class _SwipeActionsCard extends StatelessWidget { context, theme, label: 'Swipe right', - value: provider.swipeRightAction, - onChanged: provider.setSwipeRightAction, + value: settings.swipeRightAction, + onChanged: settings.setSwipeRightAction, ), const Divider(height: 1), _buildRow( context, theme, label: 'Swipe left', - value: provider.swipeLeftAction, - onChanged: provider.setSwipeLeftAction, + value: settings.swipeLeftAction, + onChanged: settings.setSwipeLeftAction, ), ], ), @@ -1157,21 +1166,21 @@ class _MediaPlayerCard extends StatelessWidget { Future _openPicker( BuildContext context, - ServerProvider provider, + SettingsController settings, ) async { final selected = await showDialog( context: context, builder: (_) => - _SeekBarStyleDialog(current: provider.mediaProgressBarStyle), + _SeekBarStyleDialog(current: settings.mediaProgressBarStyle), ); - if (selected != null) provider.setMediaProgressBarStyle(selected); + if (selected != null) settings.setMediaProgressBarStyle(selected); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final settings = context.watch(); return Card( child: ListTile( @@ -1182,17 +1191,17 @@ class _MediaPlayerCard extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - subtitle: Text(_seekBarStyleLabel(provider.mediaProgressBarStyle)), + subtitle: Text(_seekBarStyleLabel(settings.mediaProgressBarStyle)), trailing: SizedBox( width: 64, height: 24, child: SeekBarPreview( - style: provider.mediaProgressBarStyle, + style: settings.mediaProgressBarStyle, playedColor: colorScheme.primary, trackColor: colorScheme.outlineVariant, ), ), - onTap: () => _openPicker(context, provider), + onTap: () => _openPicker(context, settings), ), ); } @@ -1354,7 +1363,7 @@ class _CacheSettingsCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final files = context.watch(); return Card( child: Padding( @@ -1362,9 +1371,9 @@ class _CacheSettingsCard extends StatelessWidget { child: Column( children: [ RadioGroup( - groupValue: provider.cachePolicy, + groupValue: files.cachePolicy, onChanged: (value) { - if (value != null) provider.setCachePolicy(value); + if (value != null) files.setCachePolicy(value); }, child: Column( children: [ @@ -1377,7 +1386,7 @@ class _CacheSettingsCard extends StatelessWidget { ], ), ), - if (provider.cachePolicy == CachePolicy.interval) + if (files.cachePolicy == CachePolicy.interval) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( @@ -1392,7 +1401,7 @@ class _CacheSettingsCard extends StatelessWidget { style: theme.textTheme.bodyMedium, ), Text( - '${provider.cacheIntervalMinutes} min', + '${files.cacheIntervalMinutes} min', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1400,13 +1409,13 @@ class _CacheSettingsCard extends StatelessWidget { ], ), Slider( - value: provider.cacheIntervalMinutes.toDouble(), + value: files.cacheIntervalMinutes.toDouble(), min: 1, max: 60, divisions: 59, - label: '${provider.cacheIntervalMinutes} min', + label: '${files.cacheIntervalMinutes} min', onChanged: (value) => - provider.setCacheIntervalMinutes(value.round()), + files.setCacheIntervalMinutes(value.round()), ), ], ), @@ -1423,7 +1432,7 @@ class _TabSettingsCard extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); + final settings = context.watch(); return Card( clipBehavior: Clip.antiAlias, @@ -1431,20 +1440,20 @@ class _TabSettingsCard extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), onReorderItem: (oldIndex, newIndex) { - final order = List.from(provider.tabOrder); + final order = List.from(settings.tabOrder); final tab = order.removeAt(oldIndex); order.insert(newIndex, tab); - provider.setTabOrder(order); + settings.setTabOrder(order); }, children: [ - for (final tab in provider.tabOrder) + for (final tab in settings.tabOrder) _TabConfigRow( key: ValueKey(tab), tab: tab, - isVisible: !provider.hiddenTabs.contains(tab), - isDefault: provider.defaultTab == tab, + isVisible: !settings.hiddenTabs.contains(tab), + isDefault: settings.defaultTab == tab, onVisibilityChanged: (value) { - final error = provider.setTabHidden(tab, !value); + final error = settings.setTabHidden(tab, !value); if (error != null && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1454,7 +1463,7 @@ class _TabSettingsCard extends StatelessWidget { ); } }, - onSetDefault: () => provider.setDefaultTab(tab), + onSetDefault: () => settings.setDefaultTab(tab), ), ], ), diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index dd898d3..fb8e8e0 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; import '../widgets/synced_header_scaffold.dart'; @@ -15,13 +15,13 @@ class ActivityView extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); - final activities = provider.activities; + final files = context.watch(); + final activities = files.activities; final List contentSlivers = [ const SliverToBoxAdapter(child: SizedBox(height: 16)), - if (provider.isLoading && activities.isEmpty) + if (files.isLoading && activities.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), @@ -134,7 +134,6 @@ class ActivityView extends StatelessWidget { return SyncedHeaderScaffold( scrollController: scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], contentSlivers: contentSlivers, ); diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 0d88575..03c98c4 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -7,7 +7,11 @@ import 'package:share_plus/share_plus.dart'; import '../models/app_tab.dart'; import '../models/nextcloud_item.dart'; import '../models/selection_action.dart'; -import '../providers/server_provider.dart'; +import '../providers/favorites_controller.dart'; +import '../providers/files_controller.dart'; +import '../providers/item_operations.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; import '../services/download_service.dart'; import '../widgets/details/details_sheet.dart'; import '../widgets/item_icon.dart'; @@ -24,7 +28,7 @@ import 'move_copy_destination_picker.dart'; /// Every favorited file/folder across the whole account, account-wide - a /// real tab rather than a filter toggle scoped to whatever folder the -/// Files tab happens to be browsing (see `ServerProvider.fetchAllFavorites` +/// Files tab happens to be browsing (see `FavoritesController.fetchAll` /// for why: a favorited item several folders deep needs to show up /// regardless of whether its parent folders are themselves favorited, /// which a current-folder-only filter can never do, and a flat account- @@ -32,7 +36,7 @@ import 'move_copy_destination_picker.dart'; /// toggle had - the trail showing wherever Files was last browsing while /// the content showed something else entirely - simply can't happen). /// Shares Files' own sort/hidden/storage-scope/grid-list display prefs -/// (via `ServerProvider.applyFilesDisplayPrefs`) rather than a separate +/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate /// parallel settings dimension. class FavoritesView extends StatefulWidget { final ScrollController scrollController; @@ -128,13 +132,12 @@ class _FavoritesViewState extends State /// back on this tab either way. void _openFavorite( BuildContext context, - ServerProvider provider, NextcloudItem item, List siblings, ) { if (item.isFolder) { - provider.navigateToAbsoluteFolder(item.path); - provider.requestTab(AppTab.files); + context.read().navigateToAbsoluteFolder(item.path); + context.read().requestTab(AppTab.files); } else { Navigator.push( context, @@ -145,42 +148,39 @@ class _FavoritesViewState extends State /// The bulk actions shown in the sticky selection toolbar for the /// currently-selected items - same set Files/Photos offer. - List _buildSelectionActions( - ServerProvider provider, - List selected, - ) { + List _buildSelectionActions(List selected) { return [ SelectionAction( icon: Icons.favorite_border_rounded, label: 'Remove from favorites', - onTap: () => _unfavoriteSelected(provider, selected), + onTap: () => _unfavoriteSelected(selected), ), SelectionAction( icon: Icons.share_rounded, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) - : _shareSelected(context, provider, selected), + : _shareSelected(context, selected), ), SelectionAction( icon: Icons.download_rounded, label: 'Download', - onTap: () => _downloadSelected(context, provider, selected), + onTap: () => _downloadSelected(context, selected), ), SelectionAction( icon: Icons.delete_outline_rounded, label: 'Delete', - onTap: () => _confirmDeleteSelected(context, provider, selected), + onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( icon: Icons.copy_rounded, label: 'Copy', - onTap: () => _moveOrCopySelected(provider, selected, copy: true), + onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( icon: Icons.drive_file_move_rounded, label: 'Move', - onTap: () => _moveOrCopySelected(provider, selected, copy: false), + onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( @@ -191,21 +191,19 @@ class _FavoritesViewState extends State ]; } - Future _unfavoriteSelected( - ServerProvider provider, - List items, - ) async { + Future _unfavoriteSelected(List items) async { + final ops = context.read(); for (final item in items) { - await provider.toggleItemFavorite(item); + await ops.toggleItemFavorite(item); } _clearSelection(); } Future _shareSelected( BuildContext context, - ServerProvider provider, List items, ) async { + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( @@ -216,7 +214,7 @@ class _FavoritesViewState extends State final lines = []; for (final item in items) { - final link = await provider.createShareLink(item); + final link = await ops.createShareLink(item); if (link != null) { lines.add(items.length > 1 ? '${item.name}: $link' : link); } @@ -252,16 +250,16 @@ class _FavoritesViewState extends State /// `FilesView._downloadSelected`'s identical doc comment for why. Future _downloadSelected( BuildContext context, - ServerProvider provider, List items, ) async { + final session = context.read(); final files = items.where((i) => !i.isFolder).toList(); _clearSelection(); if (files.isEmpty) return; final messenger = ScaffoldMessenger.of(context); try { - await DownloadService.startDownload(provider, files); + await DownloadService.startDownload(session, files); messenger.showSnackBar( SnackBar( content: Text( @@ -284,7 +282,6 @@ class _FavoritesViewState extends State Future _confirmDeleteSelected( BuildContext context, - ServerProvider provider, List items, ) async { final confirmed = await showDialog( @@ -313,10 +310,11 @@ class _FavoritesViewState extends State ); if (confirmed != true || !context.mounted) return; + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); var succeeded = 0; for (final item in items) { - final success = await provider.deleteItem(item.path); + final success = await ops.deleteItem(item.path); if (success) succeeded++; } @@ -330,7 +328,6 @@ class _FavoritesViewState extends State } Future _moveOrCopySelected( - ServerProvider provider, List items, { required bool copy, }) async { @@ -351,7 +348,7 @@ class _FavoritesViewState extends State ); } - Widget _buildControlsRow(ServerProvider provider) { + Widget _buildControlsRow(FilesController files) { return SizedBox( height: 44, child: SingleChildScrollView( @@ -360,28 +357,28 @@ class _FavoritesViewState extends State children: [ IconButton( icon: Icon( - provider.filesSortAscending + files.filesSortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: provider.toggleFilesSortOrder, + tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', + onPressed: files.toggleFilesSortOrder, ), SizedBox( width: 130, child: SortMenuButton( - field: provider.filesSortField, - onChanged: provider.setFilesSortField, + field: files.filesSortField, + onChanged: files.setFilesSortField, ), ), ToggleIconButton( - icon: provider.showHiddenFiles + icon: files.showHiddenFiles ? Icons.visibility_rounded : Icons.visibility_off_rounded, - isSelected: provider.showHiddenFiles, - onTap: () => provider.toggleShowHiddenFiles(), + isSelected: files.showHiddenFiles, + onTap: () => files.toggleShowHiddenFiles(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), @@ -389,14 +386,14 @@ class _FavoritesViewState extends State children: [ ToggleIconButton( icon: Symbols.circles_rounded, - isSelected: provider.storageScope == StorageScope.cloud, - onTap: () => provider.setStorageScope(StorageScope.cloud), + isSelected: files.storageScope == StorageScope.cloud, + onTap: () => files.setStorageScope(StorageScope.cloud), tooltip: 'Cloud storage', ), ToggleIconButton( icon: Symbols.hard_drive_rounded, - isSelected: provider.storageScope == StorageScope.external, - onTap: () => provider.setStorageScope(StorageScope.external), + isSelected: files.storageScope == StorageScope.external, + onTap: () => files.setStorageScope(StorageScope.external), tooltip: 'External storage', ), ], @@ -406,14 +403,14 @@ class _FavoritesViewState extends State children: [ ToggleIconButton( icon: Icons.view_list_rounded, - isSelected: !provider.isGridView, - onTap: () => provider.setGridView(false), + isSelected: !files.isGridView, + onTap: () => files.setGridView(false), tooltip: 'List view', ), ToggleIconButton( icon: Icons.grid_view_rounded, - isSelected: provider.isGridView, - onTap: () => provider.setGridView(true), + isSelected: files.isGridView, + onTap: () => files.setGridView(true), tooltip: 'Grid view', ), ], @@ -427,11 +424,11 @@ class _FavoritesViewState extends State Widget _buildListTile( BuildContext context, NextcloudItem item, - ServerProvider provider, List siblings, ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final session = context.watch(); final isSelected = _selectedIds.contains(item.id); return Padding( @@ -447,7 +444,7 @@ class _FavoritesViewState extends State if (_isSelecting) { _toggleSelection(item); } else { - _openFavorite(context, provider, item, siblings); + _openFavorite(context, item, siblings); } }, onLongPress: () => _toggleSelection(item), @@ -461,7 +458,7 @@ class _FavoritesViewState extends State checkmarkSize: 24, child: ItemThumbnail( item: item, - provider: provider, + service: session.service, size: 44, borderRadius: 12, iconSize: 22, @@ -503,10 +500,10 @@ class _FavoritesViewState extends State Widget _buildGridCard( BuildContext context, NextcloudItem item, - ServerProvider provider, List siblings, ) { final theme = Theme.of(context); + final session = context.watch(); final isSelected = _selectedIds.contains(item.id); final isMedia = (item.type == NextcloudItemType.image || @@ -523,7 +520,7 @@ class _FavoritesViewState extends State if (_isSelecting) { _toggleSelection(item); } else { - _openFavorite(context, provider, item, siblings); + _openFavorite(context, item, siblings); } }, onLongPress: () => _toggleSelection(item), @@ -533,7 +530,7 @@ class _FavoritesViewState extends State child: isMedia ? Image.network( item.previewUrl!, - headers: provider.service?.authHeaders, + headers: session.service?.authHeaders, fit: BoxFit.cover, filterQuality: FilterQuality.low, gaplessPlayback: true, @@ -587,16 +584,17 @@ class _FavoritesViewState extends State Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final favoritesController = context.watch(); + final filesController = context.watch(); if (!_requested) { _requested = true; WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.fetchAllFavorites(), + (_) => favoritesController.fetchAll(), ); } - final favorites = provider.favoriteItems; + final favorites = favoritesController.items; final siblings = favorites.where((i) => !i.isFolder).toList(); final selectedItems = favorites .where((i) => _selectedIds.contains(i.id)) @@ -609,17 +607,17 @@ class _FavoritesViewState extends State height: 60, child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: _buildControlsRow(provider), + child: _buildControlsRow(filesController), ), ), ), const SliverToBoxAdapter(child: SizedBox(height: 8)), - if (provider.isFavoritesLoading && favorites.isEmpty) + if (favoritesController.isLoading && favorites.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (provider.favoritesErrorMessage != null) + else if (favoritesController.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -643,7 +641,7 @@ class _FavoritesViewState extends State ), const SizedBox(height: 8), Text( - provider.favoritesErrorMessage!, + favoritesController.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -651,7 +649,7 @@ class _FavoritesViewState extends State ), const SizedBox(height: 20), FilledButton.icon( - onPressed: provider.fetchAllFavorites, + onPressed: favoritesController.fetchAll, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), @@ -683,7 +681,7 @@ class _FavoritesViewState extends State ), ), ) - else if (provider.isGridView) + else if (filesController.isGridView) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverGrid( @@ -694,12 +692,7 @@ class _FavoritesViewState extends State mainAxisSpacing: 12, ), delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard( - context, - favorites[index], - provider, - siblings, - ); + return _buildGridCard(context, favorites[index], siblings); }, childCount: favorites.length), ), ) @@ -708,12 +701,7 @@ class _FavoritesViewState extends State padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { - return _buildListTile( - context, - favorites[index], - provider, - siblings, - ); + return _buildListTile(context, favorites[index], siblings); }, childCount: favorites.length), ), ), @@ -728,11 +716,10 @@ class _FavoritesViewState extends State }, child: SyncedHeaderScaffold( scrollController: widget.scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: provider.fetchAllFavorites, + onRefresh: favoritesController.fetchAll, selectionBar: _isSelecting - ? _buildSelectionBar(context, theme, provider, selectedItems) + ? _buildSelectionBar(context, theme, selectedItems) : null, contentSlivers: contentSlivers, ), @@ -745,7 +732,6 @@ class _FavoritesViewState extends State Widget _buildSelectionBar( BuildContext context, ThemeData theme, - ServerProvider provider, List selectedItems, ) { return Padding( @@ -783,10 +769,7 @@ class _FavoritesViewState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - for (final action in _buildSelectionActions( - provider, - selectedItems, - )) + for (final action in _buildSelectionActions(selectedItems)) IconButton( icon: Icon(action.icon, size: 20), tooltip: action.label, diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index cc5e192..7078294 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -9,7 +9,9 @@ import 'package:pdfx/pdfx.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/item_operations.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; import '../services/download_service.dart'; import '../widgets/details/details_sheet.dart'; import '../widgets/frosted_glass_container.dart'; @@ -148,13 +150,13 @@ class _FileViewerScreenState extends State { } Future _downloadToTemp( - ServerProvider provider, + SessionController session, NextcloudItem item, ) async { final tempDir = await getTemporaryDirectory(); final savePath = p.join(tempDir.path, item.name); setState(() => _downloadProgress = 0); - await provider.service!.downloadToFile( + await session.service!.downloadToFile( item.path, savePath, onProgress: (received, total) { @@ -167,11 +169,11 @@ class _FileViewerScreenState extends State { return savePath; } - Future _openExternally(ServerProvider provider) async { + Future _openExternally(SessionController session) async { final item = _currentItem; setState(() => _isBusy = true); try { - final path = await _downloadToTemp(provider, item); + final path = await _downloadToTemp(session, item); final result = await OpenFile.open(path); if (mounted && result.type != ResultType.done) { ScaffoldMessenger.of(context).showSnackBar( @@ -194,10 +196,10 @@ class _FileViewerScreenState extends State { /// `UploadService`/`ShareUploadService.kt` on the upload side: a real /// Android Service survives the app being closed mid-download, and one /// notification covers progress/cancel instead of blocking this screen. - Future _downloadToDevice(ServerProvider provider) async { + Future _downloadToDevice(SessionController session) async { final item = _currentItem; try { - await DownloadService.startDownload(provider, [item]); + await DownloadService.startDownload(session, [item]); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -216,16 +218,16 @@ class _FileViewerScreenState extends State { } } - Future _toggleFavorite(ServerProvider provider) async { + Future _toggleFavorite(ItemOperations ops) async { final item = _currentItem; - await provider.toggleItemFavorite(item); + await ops.toggleItemFavorite(item); if (!mounted) return; setState(() { _mediaItems[_currentIndex] = item.copyWith(isFavorite: !item.isFavorite); }); } - Future _deleteCurrentItem(ServerProvider provider) async { + Future _deleteCurrentItem(ItemOperations ops) async { final item = _currentItem; final confirmed = await showDialog( context: context, @@ -254,7 +256,7 @@ class _FileViewerScreenState extends State { if (confirmed != true || !mounted) return; setState(() => _isBusy = true); - final success = await provider.deleteItem(item.path); + final success = await ops.deleteItem(item.path); if (!mounted) return; setState(() => _isBusy = false); @@ -283,7 +285,9 @@ class _FileViewerScreenState extends State { @override Widget build(BuildContext context) { - final provider = context.read(); + final session = context.read(); + final settings = context.read(); + final ops = context.read(); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -296,7 +300,7 @@ class _FileViewerScreenState extends State { Positioned.fill( child: Container( color: colorScheme.surface, - child: _buildBody(context, provider), + child: _buildBody(context, session), ), ), if (_downloadProgress != null) @@ -322,8 +326,8 @@ class _FileViewerScreenState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: FrostedGlassContainer( - opacity: provider.bottomBarOpacity, - blurSigma: provider.bottomBarBlur, + opacity: settings.bottomBarOpacity, + blurSigma: settings.bottomBarBlur, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -367,13 +371,13 @@ class _FileViewerScreenState extends State { child: _MediaActionBar( isFavorite: _currentItem.isFavorite, isBusy: _isBusy, - opacity: provider.bottomBarOpacity, - blurSigma: provider.bottomBarBlur, + opacity: settings.bottomBarOpacity, + blurSigma: settings.bottomBarBlur, onShare: () => ShareSheet.show(context, _currentItem), - onFavorite: () => _toggleFavorite(provider), - onDelete: () => _deleteCurrentItem(provider), - onOpenExternally: () => _openExternally(provider), - onDownload: () => _downloadToDevice(provider), + onFavorite: () => _toggleFavorite(ops), + onDelete: () => _deleteCurrentItem(ops), + onOpenExternally: () => _openExternally(session), + onDownload: () => _downloadToDevice(session), onDetails: () => DetailsSheet.show(context, _currentItem), ), ), @@ -385,10 +389,11 @@ class _FileViewerScreenState extends State { ); } - Widget _buildBody(BuildContext context, ServerProvider provider) { + Widget _buildBody(BuildContext context, SessionController session) { if (!_isSwipeable) { - return _buildStaticPreview(context, provider); + return _buildStaticPreview(context, session); } + final settings = context.read(); return PageView.builder( controller: _pageController, @@ -399,24 +404,24 @@ class _FileViewerScreenState extends State { if (mediaItem.type == NextcloudItemType.image) { return _ImagePreview( key: ValueKey(mediaItem.id), - url: provider.service!.fileUrl(mediaItem.path), - headers: provider.service!.authHeaders, + url: session.service!.fileUrl(mediaItem.path), + headers: session.service!.authHeaders, ); } return _VideoPreview( key: ValueKey(mediaItem.id), - url: provider.service!.fileUrl(mediaItem.path), - headers: provider.service!.authHeaders, + url: session.service!.fileUrl(mediaItem.path), + headers: session.service!.authHeaders, isActive: index == _currentIndex, controlsBottomOffset: _controlsVisible ? 108 : 24, - opacity: provider.bottomBarOpacity, - blurSigma: provider.bottomBarBlur, + opacity: settings.bottomBarOpacity, + blurSigma: settings.bottomBarBlur, ); }, ); } - Widget _buildStaticPreview(BuildContext context, ServerProvider provider) { + Widget _buildStaticPreview(BuildContext context, SessionController session) { switch (widget.item.type) { case NextcloudItemType.image: case NextcloudItemType.video: @@ -424,15 +429,15 @@ class _FileViewerScreenState extends State { return const SizedBox.shrink(); default: if (_isPdf) { - return _PdfPreview(item: widget.item, provider: provider); + return _PdfPreview(item: widget.item, session: session); } if (_isText) { - return _TextPreview(item: widget.item, provider: provider); + return _TextPreview(item: widget.item, session: session); } return _UnsupportedPreview( item: widget.item, isBusy: _isBusy, - onOpenExternally: () => _openExternally(provider), + onOpenExternally: () => _openExternally(session), onOpenDetails: () => DetailsSheet.show(context, widget.item), ); } @@ -752,7 +757,7 @@ class _VideoPreviewState extends State<_VideoPreview> { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final progressBarStyle = context - .watch() + .watch() .mediaProgressBarStyle; if (_error != null) { return Center( @@ -966,9 +971,9 @@ class _WavySeekBarState extends State<_WavySeekBar> class _PdfPreview extends StatefulWidget { final NextcloudItem item; - final ServerProvider provider; + final SessionController session; - const _PdfPreview({required this.item, required this.provider}); + const _PdfPreview({required this.item, required this.session}); @override State<_PdfPreview> createState() => _PdfPreviewState(); @@ -986,7 +991,7 @@ class _PdfPreviewState extends State<_PdfPreview> { Future _load() async { try { - final bytes = await widget.provider.service!.fetchBytes(widget.item.path); + final bytes = await widget.session.service!.fetchBytes(widget.item.path); if (!mounted) return; setState(() { _controller = PdfControllerPinch( @@ -1033,9 +1038,9 @@ class _PdfPreviewState extends State<_PdfPreview> { class _TextPreview extends StatefulWidget { final NextcloudItem item; - final ServerProvider provider; + final SessionController session; - const _TextPreview({required this.item, required this.provider}); + const _TextPreview({required this.item, required this.session}); @override State<_TextPreview> createState() => _TextPreviewState(); @@ -1053,7 +1058,7 @@ class _TextPreviewState extends State<_TextPreview> { Future _load() async { try { - final bytes = await widget.provider.service!.fetchBytes(widget.item.path); + final bytes = await widget.session.service!.fetchBytes(widget.item.path); if (mounted) { setState(() => _content = utf8.decode(bytes, allowMalformed: true)); } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index e4105fe..120a587 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -9,7 +9,12 @@ import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; import '../models/selection_action.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/item_operations.dart'; +import '../providers/pick_controller.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; +import '../providers/sync_status_controller.dart'; import '../services/download_service.dart'; import '../services/share_intent_service.dart'; import '../widgets/breadcrumbs.dart'; @@ -204,16 +209,14 @@ class _FilesViewState extends State /// folders are still browsable, a matching file either toggles selection /// (multi-select requests) or immediately finishes the pick, and a /// non-matching file (wrong mime type for the caller) is rejected. - void _handlePickTap( - BuildContext context, - ServerProvider provider, - NextcloudItem item, - ) { + void _handlePickTap(BuildContext context, NextcloudItem item) { + final files = context.read(); + final pick = context.read(); if (item.isFolder) { - provider.navigateToFolder(item.path); + files.navigateToFolder(item.path); return; } - if (!provider.itemMatchesPickFilter(item)) { + if (!pick.itemMatchesPickFilter(item)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("This app can't accept this file type"), @@ -222,25 +225,27 @@ class _FilesViewState extends State ); return; } - if (provider.pickRequest!.allowMultiple) { + if (pick.pickRequest!.allowMultiple) { _toggleSelection(item); } else { - provider.confirmPick([item]); + pick.confirmPick([item]); } } /// The bulk actions shown in the sticky selection toolbar for the /// currently-selected items. List _buildSelectionActions( - ServerProvider provider, + BuildContext context, List selected, ) { - if (provider.isPicking) { + final pick = context.watch(); + final sync = context.watch(); + if (pick.isPicking) { return [ SelectionAction( icon: Icons.check_rounded, label: 'Use ${selected.length} item(s)', - onTap: () => provider.confirmPick(selected), + onTap: () => pick.confirmPick(selected), ), ]; } @@ -252,52 +257,52 @@ class _FilesViewState extends State label: selected.every((i) => i.isFavorite) ? 'Remove from favorites' : 'Favorite', - onTap: () => _favoriteSelected(provider, selected), + onTap: () => _favoriteSelected(context, selected), ), SelectionAction( icon: Icons.share_rounded, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) - : _shareSelected(context, provider, selected), + : _shareSelected(context, selected), ), SelectionAction( icon: Icons.download_rounded, label: 'Download', - onTap: () => _downloadSelected(context, provider, selected), + onTap: () => _downloadSelected(context, selected), ), SelectionAction( icon: Icons.delete_outline_rounded, label: 'Delete', - onTap: () => _confirmDeleteSelected(context, provider, selected), + onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( icon: Icons.copy_rounded, label: 'Copy', - onTap: () => _moveOrCopySelected(provider, selected, copy: true), + onTap: () => _moveOrCopySelected(context, selected, copy: true), ), SelectionAction( icon: Icons.drive_file_move_rounded, label: 'Move', - onTap: () => _moveOrCopySelected(provider, selected, copy: false), + onTap: () => _moveOrCopySelected(context, selected, copy: false), ), if (selected.length == 1) SelectionAction( icon: Icons.drive_file_rename_outline_rounded, label: 'Rename', - onTap: () => _renameItem(provider, selected.single), + onTap: () => _renameItem(selected.single), ), if (selected.length == 1) SelectionAction( - icon: provider.isPathSynced(selected.single.path) + icon: sync.isPathSynced(selected.single.path) ? Icons.sync_rounded : Icons.sync_outlined, - label: provider.isPathSynced(selected.single.path) + label: sync.isPathSynced(selected.single.path) ? 'Stop syncing to device' : 'Sync to device', - onTap: () => provider.isPathSynced(selected.single.path) - ? provider.removeSyncedPath(selected.single.path) - : provider.addSyncedPath(selected.single.path), + onTap: () => sync.isPathSynced(selected.single.path) + ? sync.removeSyncedPath(selected.single.path) + : sync.addSyncedPath(selected.single.path), ), if (selected.length == 1) SelectionAction( @@ -308,7 +313,7 @@ class _FilesViewState extends State ]; } - Future _renameItem(ServerProvider provider, NextcloudItem item) async { + Future _renameItem(NextcloudItem item) async { final controller = TextEditingController(text: item.name); final newName = await showDialog( context: context, @@ -345,7 +350,8 @@ class _FilesViewState extends State if (!mounted) return; final messenger = ScaffoldMessenger.of(context); - final success = await provider.renameItem(item, newName); + final ops = context.read(); + final success = await ops.renameItem(item, newName); _clearSelection(); messenger.showSnackBar( SnackBar( @@ -359,9 +365,9 @@ class _FilesViewState extends State Future _shareSelected( BuildContext context, - ServerProvider provider, List items, ) async { + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( @@ -372,7 +378,7 @@ class _FilesViewState extends State final lines = []; for (final item in items) { - final link = await provider.createShareLink(item); + final link = await ops.createShareLink(item); if (link != null) { lines.add(items.length > 1 ? '${item.name}: $link' : link); } @@ -408,14 +414,14 @@ class _FilesViewState extends State Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final files = context.watch(); - final pathDepth = provider.pathStack.length; + final pathDepth = files.pathStack.length; final navigatingDeeper = pathDepth > _lastPathDepth; _lastPathDepth = pathDepth; - final hasBreadcrumbs = provider.pathStack.length > 1; - final selectedItems = provider.items + final hasBreadcrumbs = files.pathStack.length > 1; + final selectedItems = files.items .where((i) => _selectedIds.contains(i.id)) .toList(); @@ -445,16 +451,16 @@ class _FilesViewState extends State children: [ IconButton( icon: Icon( - provider.filesSortAscending + files.filesSortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.filesSortAscending + tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: provider.toggleFilesSortOrder, + onPressed: files.toggleFilesSortOrder, ), // A plain width, not Expanded - this row scrolls // horizontally now, which gives every control unbounded @@ -462,16 +468,16 @@ class _FilesViewState extends State SizedBox( width: 130, child: SortMenuButton( - field: provider.filesSortField, - onChanged: provider.setFilesSortField, + field: files.filesSortField, + onChanged: files.setFilesSortField, ), ), ToggleIconButton( - icon: provider.showHiddenFiles + icon: files.showHiddenFiles ? Icons.visibility_rounded : Icons.visibility_off_rounded, - isSelected: provider.showHiddenFiles, - onTap: () => provider.toggleShowHiddenFiles(), + isSelected: files.showHiddenFiles, + onTap: () => files.toggleShowHiddenFiles(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), @@ -479,17 +485,17 @@ class _FilesViewState extends State children: [ ToggleIconButton( icon: Symbols.circles_rounded, - isSelected: provider.storageScope == StorageScope.cloud, + isSelected: files.storageScope == StorageScope.cloud, onTap: () => - provider.setStorageScope(StorageScope.cloud), + files.setStorageScope(StorageScope.cloud), tooltip: 'Cloud storage', ), ToggleIconButton( icon: Symbols.hard_drive_rounded, isSelected: - provider.storageScope == StorageScope.external, + files.storageScope == StorageScope.external, onTap: () => - provider.setStorageScope(StorageScope.external), + files.setStorageScope(StorageScope.external), tooltip: 'External storage', ), ], @@ -500,17 +506,17 @@ class _FilesViewState extends State ToggleIconButton( icon: Icons.select_all_rounded, isSelected: - provider.filesTypeFilter == FilesTypeFilter.all, + files.filesTypeFilter == FilesTypeFilter.all, onTap: () => - provider.setFilesTypeFilter(FilesTypeFilter.all), + files.setFilesTypeFilter(FilesTypeFilter.all), tooltip: 'Files & folders', ), ToggleIconButton( icon: Icons.insert_drive_file_outlined, isSelected: - provider.filesTypeFilter == + files.filesTypeFilter == FilesTypeFilter.filesOnly, - onTap: () => provider.setFilesTypeFilter( + onTap: () => files.setFilesTypeFilter( FilesTypeFilter.filesOnly, ), tooltip: 'Files only', @@ -518,9 +524,9 @@ class _FilesViewState extends State ToggleIconButton( icon: Icons.folder_outlined, isSelected: - provider.filesTypeFilter == + files.filesTypeFilter == FilesTypeFilter.foldersOnly, - onTap: () => provider.setFilesTypeFilter( + onTap: () => files.setFilesTypeFilter( FilesTypeFilter.foldersOnly, ), tooltip: 'Folders only', @@ -532,14 +538,14 @@ class _FilesViewState extends State children: [ ToggleIconButton( icon: Icons.view_list_rounded, - isSelected: !provider.isGridView, - onTap: () => provider.setGridView(false), + isSelected: !files.isGridView, + onTap: () => files.setGridView(false), tooltip: 'List view', ), ToggleIconButton( icon: Icons.grid_view_rounded, - isSelected: provider.isGridView, - onTap: () => provider.setGridView(true), + isSelected: files.isGridView, + onTap: () => files.setGridView(true), tooltip: 'Grid view', ), ], @@ -553,8 +559,8 @@ class _FilesViewState extends State SizedBox( height: 32, child: Breadcrumbs( - pathStack: provider.pathStack, - onTap: (index) => provider.navigateToPathIndex(index), + pathStack: files.pathStack, + onTap: (index) => files.navigateToPathIndex(index), ), ), ], @@ -574,12 +580,12 @@ class _FilesViewState extends State ), ), // Files List / Grid - if (provider.isLoading) + if (files.isLoading) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (provider.errorMessage != null) + else if (files.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -603,7 +609,7 @@ class _FilesViewState extends State ), const SizedBox(height: 8), Text( - provider.errorMessage!, + files.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -611,7 +617,7 @@ class _FilesViewState extends State ), const SizedBox(height: 20), FilledButton.icon( - onPressed: provider.refreshData, + onPressed: files.refreshData, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry Connection'), ), @@ -620,7 +626,7 @@ class _FilesViewState extends State ), ), ) - else if (provider.items.isEmpty) + else if (files.items.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -643,7 +649,7 @@ class _FilesViewState extends State ), ), ) - else if (provider.isGridView) + else if (files.isGridView) SliverPadding( key: const ValueKey('files-grid'), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), @@ -655,14 +661,14 @@ class _FilesViewState extends State mainAxisSpacing: 12, ), delegate: SliverChildBuilderDelegate((context, index) { - final item = provider.items[index]; + final item = files.items[index]; return _FolderEnterAnimation( - key: ValueKey('${provider.currentFolderPath}::${item.id}'), + key: ValueKey('${files.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, - child: _buildGridCard(context, item, provider), + child: _buildGridCard(context, item), ); - }, childCount: provider.items.length), + }, childCount: files.items.length), ), ) else @@ -671,14 +677,14 @@ class _FilesViewState extends State padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { - final item = provider.items[index]; + final item = files.items[index]; return _FolderEnterAnimation( - key: ValueKey('${provider.currentFolderPath}::${item.id}'), + key: ValueKey('${files.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, - child: _buildListTile(context, item, provider), + child: _buildListTile(context, item), ); - }, childCount: provider.items.length), + }, childCount: files.items.length), ), ), @@ -693,29 +699,28 @@ class _FilesViewState extends State ]; return PopScope( - canPop: !_isSelecting && provider.pathStack.length <= 1, + canPop: !_isSelecting && files.pathStack.length <= 1, onPopInvokedWithResult: (didPop, result) { if (didPop) return; if (_isSelecting) { _clearSelection(); } else { - provider.navigateUp(); + files.navigateUp(); } }, child: SyncedHeaderScaffold( scrollController: widget.scrollController, - provider: provider, actions: [ IconButton( icon: const Icon(Icons.add_rounded), tooltip: 'New', - onPressed: () => _showCreateMenu(context, provider), + onPressed: () => _showCreateMenu(context), ), const MoreTabsButton(), const ProfileAvatarButton(), ], selectionBar: _isSelecting - ? _buildSelectionBar(context, theme, provider, selectedItems) + ? _buildSelectionBar(context, theme, selectedItems) : null, contentSlivers: contentSlivers, ), @@ -728,7 +733,6 @@ class _FilesViewState extends State Widget _buildSelectionBar( BuildContext context, ThemeData theme, - ServerProvider provider, List selectedItems, ) { return Padding( @@ -771,7 +775,7 @@ class _FilesViewState extends State mainAxisSize: MainAxisSize.min, children: [ for (final action in _buildSelectionActions( - provider, + context, selectedItems, )) IconButton( @@ -790,13 +794,14 @@ class _FilesViewState extends State } Future _favoriteSelected( - ServerProvider provider, + BuildContext context, List items, ) async { + final ops = context.read(); final allFavorited = items.every((i) => i.isFavorite); for (final item in items) { if (item.isFavorite == allFavorited) { - await provider.toggleItemFavorite(item); + await ops.toggleItemFavorite(item); } } _clearSelection(); @@ -810,9 +815,10 @@ class _FilesViewState extends State /// notification for the whole selection instead of blocking here. Future _downloadSelected( BuildContext context, - ServerProvider provider, List items, ) async { + final session = context.read(); + final sync = context.read(); final files = items.where((i) => !i.isFolder).toList(); _clearSelection(); if (files.isEmpty) return; @@ -822,9 +828,9 @@ class _FilesViewState extends State // Already mirrored locally by device sync (every selected file, not // just some)? Save straight from the local copy instead of a fresh // network fetch through DownloadService - see - // ServerProvider.localSyncedFilePath. + // SyncStatusController.localSyncedFilePath. final localPaths = await Future.wait( - files.map(provider.localSyncedFilePath), + files.map(sync.localSyncedFilePath), ); if (localPaths.every((path) => path != null)) { try { @@ -859,7 +865,7 @@ class _FilesViewState extends State } try { - await DownloadService.startDownload(provider, files); + await DownloadService.startDownload(session, files); messenger.showSnackBar( SnackBar( content: Text( @@ -882,7 +888,6 @@ class _FilesViewState extends State Future _confirmDeleteSelected( BuildContext context, - ServerProvider provider, List items, ) async { final confirmed = await showDialog( @@ -912,9 +917,10 @@ class _FilesViewState extends State if (confirmed != true || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); + final ops = context.read(); var succeeded = 0; for (final item in items) { - final success = await provider.deleteItem(item.path); + final success = await ops.deleteItem(item.path); if (success) succeeded++; } @@ -928,7 +934,7 @@ class _FilesViewState extends State } Future _moveOrCopySelected( - ServerProvider provider, + BuildContext context, List items, { required bool copy, }) async { @@ -937,7 +943,7 @@ class _FilesViewState extends State items, copy: copy, ); - if (!mounted || result == null) return; + if (!context.mounted || result == null) return; _clearSelection(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -949,13 +955,15 @@ class _FilesViewState extends State ); } - Widget _buildListTile( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildListTile(BuildContext context, NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final files = context.watch(); + final pick = context.watch(); + final sync = context.watch(); + final session = context.watch(); + final settings = context.watch(); + final ops = context.read(); final isSelected = _selectedIds.contains(item.id); // No border radius here — the outer ClipRRect below is the only place @@ -968,17 +976,17 @@ class _FilesViewState extends State : colorScheme.surfaceContainerLow, child: InkWell( onTap: () { - if (provider.isPicking) { - _handlePickTap(context, provider, item); + if (pick.isPicking) { + _handlePickTap(context, item); } else if (_isSelecting) { _toggleSelection(item); } else if (item.isFolder) { - provider.navigateToFolder(item.path); + files.navigateToFolder(item.path); } else { - _openFile(context, item, provider); + _openFile(context, item); } }, - onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple + onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple ? null : () => _toggleSelection(item), child: Padding( @@ -994,7 +1002,7 @@ class _FilesViewState extends State checkmarkSize: 24, child: ItemThumbnail( item: item, - provider: provider, + service: session.service, size: 44, borderRadius: 12, iconSize: 22, @@ -1004,7 +1012,7 @@ class _FilesViewState extends State right: -2, bottom: -2, child: SyncStatusBadge( - status: provider.syncStatusFor(item), + status: sync.syncStatusFor(item), ), ), ], @@ -1039,15 +1047,15 @@ class _FilesViewState extends State ), ); - final content = _isSelecting || provider.isPicking + final content = _isSelecting || pick.isPicking ? card : SwipeableItem( itemKey: ValueKey('file-${item.id}'), itemName: item.name, - provider: provider, - onFavorite: () => provider.toggleItemFavorite(item), + settings: settings, + onFavorite: () => ops.toggleItemFavorite(item), onShare: () => ShareSheet.show(context, item), - onDelete: () => _deleteViaSwipe(provider, item), + onDelete: () => _deleteViaSwipe(item), child: card, ); @@ -1057,11 +1065,9 @@ class _FilesViewState extends State ); } - Future _deleteViaSwipe( - ServerProvider provider, - NextcloudItem item, - ) async { - final success = await provider.deleteItem(item.path); + Future _deleteViaSwipe(NextcloudItem item) async { + final ops = context.read(); + final success = await ops.deleteItem(item.path); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1073,11 +1079,10 @@ class _FilesViewState extends State ); } - Widget _buildGridCard( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildGridCard(BuildContext context, NextcloudItem item) { + final files = context.watch(); + final pick = context.watch(); + final sync = context.watch(); final isSelected = _selectedIds.contains(item.id); final isMedia = (item.type == NextcloudItemType.image || @@ -1090,17 +1095,17 @@ class _FilesViewState extends State clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - if (provider.isPicking) { - _handlePickTap(context, provider, item); + if (pick.isPicking) { + _handlePickTap(context, item); } else if (_isSelecting) { _toggleSelection(item); } else if (item.isFolder) { - provider.navigateToFolder(item.path); + files.navigateToFolder(item.path); } else { - _openFile(context, item, provider); + _openFile(context, item); } }, - onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple + onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple ? null : () => _toggleSelection(item), child: Stack( @@ -1109,13 +1114,13 @@ class _FilesViewState extends State isSelected: isSelected, checkmarkSize: 32, child: isMedia - ? _buildMediaGridContent(context, item, provider) + ? _buildMediaGridContent(context, item) : _buildPlainGridContent(context, item), ), Positioned( right: 6, bottom: 6, - child: SyncStatusBadge(status: provider.syncStatusFor(item)), + child: SyncStatusBadge(status: sync.syncStatusFor(item)), ), ], ), @@ -1126,11 +1131,8 @@ class _FilesViewState extends State /// Grid content for images/videos: 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. - Widget _buildMediaGridContent( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildMediaGridContent(BuildContext context, NextcloudItem item) { + final session = context.watch(); final theme = Theme.of(context); return LayoutBuilder( @@ -1143,7 +1145,7 @@ class _FilesViewState extends State children: [ Image.network( item.previewUrl!, - headers: provider.service?.authHeaders, + headers: session.service?.authHeaders, fit: BoxFit.cover, cacheWidth: cachePixels, cacheHeight: cachePixels, @@ -1253,18 +1255,15 @@ class _FilesViewState extends State ); } - void _openFile( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + void _openFile(BuildContext context, NextcloudItem item) { + final files = context.read(); Navigator.push( context, - FileViewerScreen.route(item: item, siblings: provider.items), + FileViewerScreen.route(item: item, siblings: files.items), ); } - void _showCreateMenu(BuildContext context, ServerProvider provider) { + void _showCreateMenu(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, @@ -1293,7 +1292,7 @@ class _FilesViewState extends State title: const Text('New Folder'), onTap: () { Navigator.pop(sheetContext); - _showCreateFolderDialog(context, provider); + _showCreateFolderDialog(context); }, ), ], @@ -1304,7 +1303,8 @@ class _FilesViewState extends State ); } - void _showCreateFolderDialog(BuildContext context, ServerProvider provider) { + void _showCreateFolderDialog(BuildContext context) { + final files = context.read(); final controller = TextEditingController(); showDialog( @@ -1329,7 +1329,7 @@ class _FilesViewState extends State final name = controller.text.trim(); if (name.isNotEmpty) { Navigator.pop(context); - final success = await provider.createFolder(name); + final success = await files.createFolder(name); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/views/lock_screen_view.dart b/lib/views/lock_screen_view.dart index 8ff2700..979cf06 100644 --- a/lib/views/lock_screen_view.dart +++ b/lib/views/lock_screen_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../providers/server_provider.dart'; +import '../providers/session_controller.dart'; -/// Shown in place of the main shell whenever [ServerProvider.needsUnlock] is +/// Shown in place of the main shell whenever [SessionController.needsUnlock] is /// true - a fresh app launch with login lock configured, or returning to the /// foreground after being backgrounded. Prompts automatically on first show /// and again whenever the app resumes while still locked (e.g. the user @@ -36,13 +36,13 @@ class _LockScreenViewState extends State if (state != AppLifecycleState.resumed || !mounted || _authenticating) { return; } - if (context.read().needsUnlock) _attemptUnlock(); + if (context.read().needsUnlock) _attemptUnlock(); } Future _attemptUnlock() async { if (_authenticating || !mounted) return; setState(() => _authenticating = true); - await context.read().attemptUnlock(); + await context.read().attemptUnlock(); if (mounted) setState(() => _authenticating = false); } diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index 09ac32c..fb06668 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/saved_account.dart'; -import '../providers/server_provider.dart'; +import '../providers/session_controller.dart'; import 'login_webview_view.dart'; class LoginView extends StatefulWidget { @@ -28,7 +28,7 @@ class _LoginViewState extends State { void initState() { super.initState(); if (widget.isAddingAccount) { - _originalActiveAccountId = context.read().activeAccountId; + _originalActiveAccountId = context.read().activeAccountId; } } @@ -41,13 +41,13 @@ class _LoginViewState extends State { void _handleContinue() { if (!_formKey.currentState!.validate()) return; FocusScope.of(context).unfocus(); - context.read().startLoginFlow( + context.read().startLoginFlow( _urlController.text.trim(), addAccount: widget.isAddingAccount, ); } - /// Resumes a saved account with one tap (see [ServerProvider.switchAccount]). + /// Resumes a saved account with one tap (see [SessionController.switchAccount]). /// That can fail silently from the account's own perspective - most often /// a stored app password that no longer works (revoked server-side, or /// left over from before a since-fixed bug that deleted it too eagerly on @@ -55,8 +55,8 @@ class _LoginViewState extends State { /// silently failing every time - a delete button on the row itself (see /// [_SavedAccountRow]) is the way out. Future _continueAsAccount(SavedAccount account) async { - final provider = context.read(); - final success = await provider.switchAccount(account.id); + final session = context.read(); + final success = await session.switchAccount(account.id); if (success || !mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -98,25 +98,25 @@ class _LoginViewState extends State { }, ); if (confirmed != true || !mounted) return; - await context.read().removeAccount(account.id); + await context.read().removeAccount(account.id); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final session = context.watch(); final isAwaitingBrowser = - provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser; - final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating; + session.loginFlowStatus == LoginFlowStatus.awaitingBrowser; + final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating; // A new/refreshed account has just become active - pop back to // Settings rather than leaving this form sitting on top of it. if (widget.isAddingAccount && !_popped && - provider.loginFlowStatus == LoginFlowStatus.idle && - provider.activeAccountId != _originalActiveAccountId) { + session.loginFlowStatus == LoginFlowStatus.idle && + session.activeAccountId != _originalActiveAccountId) { _popped = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.of(context).pop(); @@ -125,14 +125,14 @@ class _LoginViewState extends State { // Every login (first account or an additional one) shows its login // page in LoginWebViewView, a real screen this app owns - see - // ServerProvider.startLoginFlow's doc comment for why. Push it the + // SessionController.startLoginFlow's doc comment for why. Push it the // moment there's a URL to show; _webViewPushed resets once that route // pops (cancelled or done) so a retry after cancelling pushes it again. if (!_webViewPushed && isAwaitingBrowser && - provider.pendingLoginUrl != null) { + session.pendingLoginUrl != null) { _webViewPushed = true; - final url = provider.pendingLoginUrl!; + final url = session.pendingLoginUrl!; WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; // A plain MaterialPageRoute's animated slide/fade transition can @@ -173,9 +173,9 @@ class _LoginViewState extends State { // from here with one tap - no need to repeat // Login Flow v2. if (!widget.isAddingAccount && - provider.accounts.isNotEmpty) ...[ + session.accounts.isNotEmpty) ...[ _SavedAccountsSection( - accounts: provider.accounts, + accounts: session.accounts, onSelect: (account) => _continueAsAccount(account), onRemove: (account) => _confirmRemoveAccount(account), ), @@ -201,10 +201,10 @@ class _LoginViewState extends State { formKey: _formKey, urlController: _urlController, isLoading: - isInitiating || provider.isLoading || isAwaitingBrowser, + isInitiating || session.isLoading || isAwaitingBrowser, errorMessage: - provider.loginFlowStatus == LoginFlowStatus.error - ? provider.errorMessage + session.loginFlowStatus == LoginFlowStatus.error + ? session.errorMessage : null, onContinue: _handleContinue, theme: theme, @@ -226,9 +226,9 @@ class _LoginViewState extends State { // gone - this view could never be popped before "add account" existed, // so that case wasn't reachable until now. return PopScope( - canPop: provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser, + canPop: session.loginFlowStatus != LoginFlowStatus.awaitingBrowser, onPopInvokedWithResult: (didPop, _) { - if (!didPop) provider.cancelLoginFlow(); + if (!didPop) session.cancelLoginFlow(); }, child: body, ); @@ -392,7 +392,7 @@ class _ServerForm extends StatelessWidget { } /// Saved accounts a logout left recoverable - tapping one resumes it via -/// [ServerProvider.switchAccount] instead of repeating Login Flow v2. +/// [SessionController.switchAccount] instead of repeating Login Flow v2. class _SavedAccountsSection extends StatelessWidget { final List accounts; final ValueChanged onSelect; diff --git a/lib/views/login_webview_view.dart b/lib/views/login_webview_view.dart index 77a173e..31ae35f 100644 --- a/lib/views/login_webview_view.dart +++ b/lib/views/login_webview_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; -import '../providers/server_provider.dart'; +import '../providers/session_controller.dart'; /// Every Login Flow v2 page - first login or an additional account - /// rendered in Flutter's own WebView rather than a Chrome Custom Tab or @@ -13,7 +13,7 @@ import '../providers/server_provider.dart'; /// exactly like every other screen in the app - and unlike a Custom Tab, /// this screen can close itself automatically once login succeeds. /// -/// See ServerProvider.startLoginFlow's doc comment for the full rationale. +/// See SessionController.startLoginFlow's doc comment for the full rationale. class LoginWebViewView extends StatefulWidget { final Uri url; @@ -56,19 +56,19 @@ class _LoginWebViewViewState extends State { } void _cancel() { - context.read().cancelLoginFlow(); + context.read().cancelLoginFlow(); Navigator.of(context).pop(); } @override Widget build(BuildContext context) { - final provider = context.watch(); + final session = context.watch(); // The login flow finished (success or error) or was cancelled // elsewhere - close this screen automatically rather than leaving it // sitting open over a flow that's no longer awaiting the browser. if (!_popped && - provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser) { + session.loginFlowStatus != LoginFlowStatus.awaitingBrowser) { _popped = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.of(context).pop(); diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index 436c994..23bb7f3 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -4,7 +4,9 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../models/move_copy_result.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/item_operations.dart'; +import '../providers/session_controller.dart'; import '../widgets/breadcrumbs.dart'; import '../widgets/item_icon.dart'; import '../widgets/more_tabs_button.dart'; @@ -18,13 +20,13 @@ import '../widgets/synced_header_scaffold.dart'; /// Destination-folder browser for moving/copying [items] (a multi-select /// batch from Files or Photos). Visually mirrors `ShareUploadView`'s /// browser (same chrome/controls/listing), but deliberately does **not** -/// reuse `ServerProvider`'s shared `currentFolderPath`/`pathStack`/`items` +/// reuse `FilesController`'s shared `currentFolderPath`/`pathStack`/`items` /// navigation state the way that screen does - this is pushed mid-browsing /// session (the user was already looking at a specific Files-tab folder /// when they selected items and tapped Move/Copy), so clobbering that /// shared state here would strand the Files tab in whatever folder this /// picker last visited. Instead it owns its own local navigation state and -/// fetches through `ServerProvider.fetchFolderListing`, a stateless +/// fetches through `FilesController.fetchFolderListing`, a stateless /// pass-through that never touches shared fields - `ShareUploadView` gets /// away with the shared state precisely because it always resets to root /// and pops all the way to the app root afterward (a cold share-intent @@ -83,7 +85,9 @@ class _MoveCopyDestinationPickerState extends State { Future _fetch(String path) async { setState(() => _isLoading = true); - final raw = await context.read().fetchFolderListing(path); + final raw = await context.read().fetchFolderListing( + path, + ); if (!mounted) return; setState(() { _rawItems = raw; @@ -109,7 +113,7 @@ class _MoveCopyDestinationPickerState extends State { return true; } - /// Client-side mirror of `ServerProvider`'s own authoritative check (see + /// Client-side mirror of `ItemOperations`' own authoritative check (see /// `_isSelfOrDescendant`) - just for disabling the confirm button with an /// explanation up front instead of letting the request round-trip and /// fail. @@ -123,11 +127,11 @@ class _MoveCopyDestinationPickerState extends State { return false; } - Future _confirm(ServerProvider provider) async { + Future _confirm(ItemOperations ops) async { setState(() => _isSubmitting = true); final result = widget.copy - ? await provider.copyItems(widget.items, _currentPath) - : await provider.moveItems(widget.items, _currentPath); + ? await ops.copyItems(widget.items, _currentPath) + : await ops.moveItems(widget.items, _currentPath); if (!mounted) return; if (result.blockedReason != null) { @@ -151,7 +155,7 @@ class _MoveCopyDestinationPickerState extends State { ); if (!mounted) return; if (choices != null) { - final resolved = await provider.resolveConflicts( + final resolved = await ops.resolveConflicts( result.conflicts, _currentPath, copy: widget.copy, @@ -171,7 +175,7 @@ class _MoveCopyDestinationPickerState extends State { ).pop(MoveCopyResult(succeeded: succeeded, failed: failed)); } - Widget _buildControlsRow(ServerProvider provider) { + Widget _buildControlsRow(FilesController files) { return SizedBox( height: 44, child: SingleChildScrollView( @@ -180,28 +184,28 @@ class _MoveCopyDestinationPickerState extends State { children: [ IconButton( icon: Icon( - provider.filesSortAscending + files.filesSortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: provider.toggleFilesSortOrder, + tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', + onPressed: files.toggleFilesSortOrder, ), SizedBox( width: 130, child: SortMenuButton( - field: provider.filesSortField, - onChanged: provider.setFilesSortField, + field: files.filesSortField, + onChanged: files.setFilesSortField, ), ), ToggleIconButton( - icon: provider.showHiddenFiles + icon: files.showHiddenFiles ? Icons.visibility_rounded : Icons.visibility_off_rounded, - isSelected: provider.showHiddenFiles, - onTap: () => provider.toggleShowHiddenFiles(), + isSelected: files.showHiddenFiles, + onTap: () => files.toggleShowHiddenFiles(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), @@ -209,14 +213,14 @@ class _MoveCopyDestinationPickerState extends State { children: [ ToggleIconButton( icon: Symbols.circles_rounded, - isSelected: provider.storageScope == StorageScope.cloud, - onTap: () => provider.setStorageScope(StorageScope.cloud), + isSelected: files.storageScope == StorageScope.cloud, + onTap: () => files.setStorageScope(StorageScope.cloud), tooltip: 'Cloud storage', ), ToggleIconButton( icon: Symbols.hard_drive_rounded, - isSelected: provider.storageScope == StorageScope.external, - onTap: () => provider.setStorageScope(StorageScope.external), + isSelected: files.storageScope == StorageScope.external, + onTap: () => files.setStorageScope(StorageScope.external), tooltip: 'External storage', ), ], @@ -226,14 +230,14 @@ class _MoveCopyDestinationPickerState extends State { children: [ ToggleIconButton( icon: Icons.view_list_rounded, - isSelected: !provider.isGridView, - onTap: () => provider.setGridView(false), + isSelected: !files.isGridView, + onTap: () => files.setGridView(false), tooltip: 'List view', ), ToggleIconButton( icon: Icons.grid_view_rounded, - isSelected: provider.isGridView, - onTap: () => provider.setGridView(true), + isSelected: files.isGridView, + onTap: () => files.setGridView(true), tooltip: 'Grid view', ), ], @@ -248,9 +252,10 @@ class _MoveCopyDestinationPickerState extends State { // the listing matches what the Files tab itself would show for this // folder) but are visually dimmed and inert, same treatment as // ShareUploadView's own destination browser. - Widget _buildListTile(NextcloudItem item, ServerProvider provider) { + Widget _buildListTile(NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final session = context.watch(); final isFolder = item.isFolder; return Padding( @@ -272,7 +277,7 @@ class _MoveCopyDestinationPickerState extends State { children: [ ItemThumbnail( item: item, - provider: provider, + service: session.service, size: 44, borderRadius: 12, iconSize: 22, @@ -312,7 +317,7 @@ class _MoveCopyDestinationPickerState extends State { ); } - Widget _buildGridCard(NextcloudItem item, ServerProvider provider) { + Widget _buildGridCard(NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final isFolder = item.isFolder; @@ -379,12 +384,13 @@ class _MoveCopyDestinationPickerState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final files = context.watch(); + final ops = context.read(); final hasBreadcrumbs = _pathStack.length > 1; final currentLabel = _currentPath == '/' ? 'Home' : _currentPath.split('/').where((s) => s.isNotEmpty).last; - final items = provider.applyFilesDisplayPrefs(_rawItems); + final items = files.applyFilesDisplayPrefs(_rawItems); final invalidDestination = _destinationIsInvalid(); final controlsColumn = Padding( @@ -392,7 +398,7 @@ class _MoveCopyDestinationPickerState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildControlsRow(provider), + _buildControlsRow(files), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), SizedBox( @@ -430,7 +436,7 @@ class _MoveCopyDestinationPickerState extends State { ), ), ) - else if (provider.isGridView) + else if (files.isGridView) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverGrid( @@ -441,7 +447,7 @@ class _MoveCopyDestinationPickerState extends State { mainAxisSpacing: 12, ), delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(items[index], provider); + return _buildGridCard(items[index]); }, childCount: items.length), ), ) @@ -450,7 +456,7 @@ class _MoveCopyDestinationPickerState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { - return _buildListTile(items[index], provider); + return _buildListTile(items[index]); }, childCount: items.length), ), ), @@ -465,7 +471,6 @@ class _MoveCopyDestinationPickerState extends State { child: Scaffold( body: SyncedHeaderScaffold( scrollController: _scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], contentSlivers: contentSlivers, ), @@ -499,7 +504,7 @@ class _MoveCopyDestinationPickerState extends State { child: FilledButton.icon( onPressed: invalidDestination || _isSubmitting ? null - : () => _confirm(provider), + : () => _confirm(ops), icon: _isSubmitting ? const SizedBox( width: 18, diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index d1fff10..7d53068 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -5,7 +5,11 @@ import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; import '../models/selection_action.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/item_operations.dart'; +import '../providers/photos_controller.dart'; +import '../providers/pick_controller.dart'; +import '../providers/session_controller.dart'; import '../services/download_service.dart'; import '../widgets/details/details_sheet.dart'; import '../widgets/more_tabs_button.dart'; @@ -111,10 +115,10 @@ class _PhotosViewState extends State /// just the matching/toggle/immediate-confirm branch. void _handlePickTap( BuildContext context, - ServerProvider provider, + PickController pick, NextcloudItem item, ) { - if (!provider.itemMatchesPickFilter(item)) { + if (!pick.itemMatchesPickFilter(item)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("This app can't accept this file type"), @@ -123,25 +127,25 @@ class _PhotosViewState extends State ); return; } - if (provider.pickRequest!.allowMultiple) { + if (pick.pickRequest!.allowMultiple) { _toggleSelection(item); } else { - provider.confirmPick([item]); + pick.confirmPick([item]); } } /// The bulk actions shown in the sticky selection toolbar for the /// currently-selected items. List _buildSelectionActions( - ServerProvider provider, + PickController pick, List selected, ) { - if (provider.isPicking) { + if (pick.isPicking) { return [ SelectionAction( icon: Icons.check_rounded, label: 'Use ${selected.length} item(s)', - onTap: () => provider.confirmPick(selected), + onTap: () => pick.confirmPick(selected), ), ]; } @@ -153,34 +157,34 @@ class _PhotosViewState extends State label: selected.every((i) => i.isFavorite) ? 'Remove from favorites' : 'Favorite', - onTap: () => _favoriteSelected(provider, selected), + onTap: () => _favoriteSelected(selected), ), SelectionAction( icon: Icons.share_rounded, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) - : _shareSelected(context, provider, selected), + : _shareSelected(context, selected), ), SelectionAction( icon: Icons.download_rounded, label: 'Download', - onTap: () => _downloadSelected(context, provider, selected), + onTap: () => _downloadSelected(context, selected), ), SelectionAction( icon: Icons.delete_outline_rounded, label: 'Delete', - onTap: () => _confirmDeleteSelected(context, provider, selected), + onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( icon: Icons.copy_rounded, label: 'Copy', - onTap: () => _moveOrCopySelected(provider, selected, copy: true), + onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( icon: Icons.drive_file_move_rounded, label: 'Move', - onTap: () => _moveOrCopySelected(provider, selected, copy: false), + onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( @@ -195,16 +199,18 @@ class _PhotosViewState extends State Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final photosController = context.watch(); + final filesController = context.watch(); + final pick = context.watch(); if (!_requested) { _requested = true; WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.fetchAllMedia(), + (_) => photosController.fetchAllMedia(), ); } - final photos = provider.photoItems; + final photos = photosController.items; final selectedItems = photos .where((i) => _selectedIds.contains(i.id)) .toList(); @@ -217,38 +223,38 @@ class _PhotosViewState extends State children: [ IconButton( icon: Icon( - provider.photosSortAscending + photosController.sortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.photosSortAscending + tooltip: photosController.sortAscending ? 'Ascending' : 'Descending', - onPressed: provider.togglePhotosSortOrder, + onPressed: photosController.toggleSortOrder, ), Expanded( child: SortMenuButton( - field: provider.photosSortField, - onChanged: provider.setPhotosSortField, + field: photosController.sortField, + onChanged: photosController.setSortField, ), ), ToggleIconButton( - icon: provider.showFavoritesOnlyPhotos + icon: photosController.showFavoritesOnly ? Icons.favorite_rounded : Icons.favorite_border_rounded, - isSelected: provider.showFavoritesOnlyPhotos, - onTap: provider.toggleFavoritesFilterPhotos, + isSelected: photosController.showFavoritesOnly, + onTap: photosController.toggleFavoritesFilter, tooltip: 'Favorites only', ), const SizedBox(width: 4), ToggleIconButton( - icon: provider.showHiddenPhotos + icon: photosController.showHidden ? Icons.visibility_rounded : Icons.visibility_off_rounded, - isSelected: provider.showHiddenPhotos, - onTap: () => provider.toggleShowHiddenPhotos(), + isSelected: photosController.showHidden, + onTap: () => photosController.toggleShowHidden(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), @@ -256,14 +262,17 @@ class _PhotosViewState extends State children: [ ToggleIconButton( icon: Symbols.circles_rounded, - isSelected: provider.storageScope == StorageScope.cloud, - onTap: () => provider.setStorageScope(StorageScope.cloud), + isSelected: filesController.storageScope == StorageScope.cloud, + onTap: () => + filesController.setStorageScope(StorageScope.cloud), tooltip: 'Cloud storage', ), ToggleIconButton( icon: Symbols.hard_drive_rounded, - isSelected: provider.storageScope == StorageScope.external, - onTap: () => provider.setStorageScope(StorageScope.external), + isSelected: + filesController.storageScope == StorageScope.external, + onTap: () => + filesController.setStorageScope(StorageScope.external), tooltip: 'External storage', ), ], @@ -283,12 +292,12 @@ class _PhotosViewState extends State ), const SliverToBoxAdapter(child: SizedBox(height: 8)), - if (provider.isMediaLoading && photos.isEmpty) + if (photosController.isLoading && photos.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (provider.mediaErrorMessage != null) + else if (photosController.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -312,7 +321,7 @@ class _PhotosViewState extends State ), const SizedBox(height: 8), Text( - provider.mediaErrorMessage!, + photosController.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -320,7 +329,7 @@ class _PhotosViewState extends State ), const SizedBox(height: 20), FilledButton.icon( - onPressed: provider.fetchAllMedia, + onPressed: photosController.fetchAllMedia, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), @@ -364,7 +373,7 @@ class _PhotosViewState extends State ), delegate: SliverChildBuilderDelegate((context, index) { final photo = photos[index]; - return _buildPhotoTile(context, photo, provider); + return _buildPhotoTile(context, photo); }, childCount: photos.length), ), ), @@ -385,11 +394,10 @@ class _PhotosViewState extends State }, child: SyncedHeaderScaffold( scrollController: widget.scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: provider.fetchAllMedia, + onRefresh: photosController.fetchAllMedia, selectionBar: _isSelecting - ? _buildSelectionBar(context, theme, provider, selectedItems) + ? _buildSelectionBar(context, theme, pick, selectedItems) : null, contentSlivers: contentSlivers, ), @@ -402,7 +410,7 @@ class _PhotosViewState extends State Widget _buildSelectionBar( BuildContext context, ThemeData theme, - ServerProvider provider, + PickController pick, List selectedItems, ) { return Padding( @@ -445,7 +453,7 @@ class _PhotosViewState extends State mainAxisSize: MainAxisSize.min, children: [ for (final action in _buildSelectionActions( - provider, + pick, selectedItems, )) IconButton( @@ -463,12 +471,10 @@ class _PhotosViewState extends State ); } - Widget _buildPhotoTile( - BuildContext context, - NextcloudItem photo, - ServerProvider provider, - ) { + Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) { final colorScheme = Theme.of(context).colorScheme; + final pick = context.watch(); + final session = context.watch(); final isSelected = _selectedIds.contains(photo.id); // No border radius on the tile itself — the outer ClipRRect below is @@ -476,15 +482,15 @@ class _PhotosViewState extends State // floating rounded corner mid-drag (see the same fix in files_view). final tile = GestureDetector( onTap: () { - if (provider.isPicking) { - _handlePickTap(context, provider, photo); + if (pick.isPicking) { + _handlePickTap(context, pick, photo); } else if (_isSelecting) { _toggleSelection(photo); } else { - _openLightbox(context, photo, provider); + _openLightbox(context, photo); } }, - onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple + onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple ? null : () => _toggleSelection(photo), child: ColoredBox( @@ -507,7 +513,7 @@ class _PhotosViewState extends State .round(); return Image.network( photo.previewUrl!, - headers: provider.service?.authHeaders, + headers: session.service?.authHeaders, fit: BoxFit.cover, cacheWidth: cachePixels, cacheHeight: cachePixels, @@ -571,25 +577,20 @@ class _PhotosViewState extends State ); } - void _openLightbox( - BuildContext context, - NextcloudItem photo, - ServerProvider provider, - ) { + void _openLightbox(BuildContext context, NextcloudItem photo) { + final photos = context.read().items; Navigator.push( context, - FileViewerScreen.route(item: photo, siblings: provider.photoItems), + FileViewerScreen.route(item: photo, siblings: photos), ); } - Future _favoriteSelected( - ServerProvider provider, - List items, - ) async { + Future _favoriteSelected(List items) async { + final ops = context.read(); final allFavorited = items.every((i) => i.isFavorite); for (final item in items) { if (item.isFavorite == allFavorited) { - await provider.toggleItemFavorite(item); + await ops.toggleItemFavorite(item); } } _clearSelection(); @@ -597,9 +598,9 @@ class _PhotosViewState extends State Future _shareSelected( BuildContext context, - ServerProvider provider, List items, ) async { + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar( @@ -610,7 +611,7 @@ class _PhotosViewState extends State final lines = []; for (final item in items) { - final link = await provider.createShareLink(item); + final link = await ops.createShareLink(item); if (link != null) { lines.add(items.length > 1 ? '${item.name}: $link' : link); } @@ -646,13 +647,13 @@ class _PhotosViewState extends State /// `FilesView._downloadSelected`'s identical doc comment for why. Future _downloadSelected( BuildContext context, - ServerProvider provider, List items, ) async { + final session = context.read(); _clearSelection(); final messenger = ScaffoldMessenger.of(context); try { - await DownloadService.startDownload(provider, items); + await DownloadService.startDownload(session, items); messenger.showSnackBar( SnackBar( content: Text( @@ -675,7 +676,6 @@ class _PhotosViewState extends State Future _confirmDeleteSelected( BuildContext context, - ServerProvider provider, List items, ) async { final confirmed = await showDialog( @@ -704,10 +704,11 @@ class _PhotosViewState extends State ); if (confirmed != true || !context.mounted) return; + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); var succeeded = 0; for (final item in items) { - final success = await provider.deleteItem(item.path); + final success = await ops.deleteItem(item.path); if (success) succeeded++; } @@ -721,7 +722,6 @@ class _PhotosViewState extends State } Future _moveOrCopySelected( - ServerProvider provider, List items, { required bool copy, }) async { diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index 6ed726b..79f225e 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/recent_controller.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; import '../widgets/synced_header_scaffold.dart'; @@ -48,7 +48,7 @@ Color _recentItemIconColor(BuildContext context, NextcloudItemType type) { } /// Recently modified files across the whole account (not folders), newest -/// first — see [ServerProvider.fetchRecent]. +/// first — see [RecentController.fetchAll]. class RecentView extends StatefulWidget { final ScrollController scrollController; @@ -65,26 +65,24 @@ class _RecentViewState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final recent = context.watch(); if (!_requested) { _requested = true; - WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.fetchRecent(), - ); + WidgetsBinding.instance.addPostFrameCallback((_) => recent.fetchAll()); } - final items = provider.recentItems; + final items = recent.items; final List contentSlivers = [ const SliverToBoxAdapter(child: SizedBox(height: 16)), - if (provider.isRecentLoading && items.isEmpty) + if (recent.isLoading && items.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (provider.recentErrorMessage != null) + else if (recent.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -108,7 +106,7 @@ class _RecentViewState extends State { ), const SizedBox(height: 8), Text( - provider.recentErrorMessage!, + recent.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -116,7 +114,7 @@ class _RecentViewState extends State { ), const SizedBox(height: 20), FilledButton.icon( - onPressed: provider.fetchRecent, + onPressed: recent.fetchAll, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), @@ -154,7 +152,7 @@ class _RecentViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = items[index]; - return _buildTile(context, item, provider); + return _buildTile(context, item); }, childCount: items.length), ), ), @@ -165,18 +163,13 @@ class _RecentViewState extends State { return SyncedHeaderScaffold( scrollController: widget.scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: provider.fetchRecent, + onRefresh: recent.fetchAll, contentSlivers: contentSlivers, ); } - Widget _buildTile( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildTile(BuildContext context, NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final iconColor = _recentItemIconColor(context, item.type); @@ -192,7 +185,7 @@ class _RecentViewState extends State { borderRadius: BorderRadius.circular(16), child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: () => _openFile(context, item, provider), + onTap: () => _openFile(context, item), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( @@ -248,12 +241,9 @@ class _RecentViewState extends State { ); } - void _openFile( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { - final siblings = provider.recentItems.where((i) => i.isMedia).toList(); + void _openFile(BuildContext context, NextcloudItem item) { + final recent = context.read(); + final siblings = recent.items.where((i) => i.isMedia).toList(); Navigator.push( context, FileViewerScreen.route(item: item, siblings: siblings), diff --git a/lib/views/search_view.dart b/lib/views/search_view.dart index 662c9bc..28f1655 100644 --- a/lib/views/search_view.dart +++ b/lib/views/search_view.dart @@ -5,7 +5,8 @@ import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/settings_controller.dart'; import 'file_viewer_screen.dart'; class SearchView extends StatefulWidget { @@ -50,13 +51,13 @@ class _SearchViewState extends State { } Future _runSearch(String query) async { - final provider = context.read(); + final files = context.read(); setState(() { _isSearching = true; _error = null; }); try { - final results = await provider.searchFiles(query); + final results = await files.searchFiles(query); if (!mounted) return; setState(() { _results = results; @@ -98,12 +99,13 @@ class _SearchViewState extends State { /// browsing before), switches the shell to the Files tab, and closes /// search. For a file, also opens it once back on Files. Future _openResult(NextcloudItem item) async { - final provider = context.read(); + final files = context.read(); + final settings = context.read(); final navigator = Navigator.of(context); final folderPath = item.isFolder ? item.path : p.dirname(item.path); - await provider.navigateToAbsoluteFolder(folderPath); - provider.requestTab(AppTab.files); + await files.navigateToAbsoluteFolder(folderPath); + settings.requestTab(AppTab.files); navigator.popUntil((route) => route.isFirst); if (!item.isFolder) { diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 6410dc2..6404379 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -4,7 +4,9 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; import '../services/share_intent_service.dart'; import '../services/upload_service.dart'; import '../widgets/breadcrumbs.dart'; @@ -43,7 +45,7 @@ class _ShareUploadViewState extends State { // Shared files have no relationship to wherever the user was last // browsing, so start the destination picker fresh at the root. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) context.read().navigateToAbsoluteFolder('/'); + if (mounted) context.read().navigateToAbsoluteFolder('/'); }); } @@ -53,11 +55,13 @@ class _ShareUploadViewState extends State { super.dispose(); } - Future _uploadHere(ServerProvider provider) async { + Future _uploadHere(SessionController session) async { final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); + final files = context.read(); + final settings = context.read(); try { - await UploadService.startUpload(provider, widget.files); + await UploadService.startUpload(session, files, widget.files); } catch (e) { messenger.showSnackBar( SnackBar( @@ -68,7 +72,7 @@ class _ShareUploadViewState extends State { return; } - provider.requestTab(AppTab.files); + settings.requestTab(AppTab.files); navigator.popUntil((route) => route.isFirst); messenger.showSnackBar( SnackBar( @@ -82,7 +86,7 @@ class _ShareUploadViewState extends State { ); } - Widget _buildControlsRow(ServerProvider provider) { + Widget _buildControlsRow(FilesController files) { return SizedBox( height: 44, child: SingleChildScrollView( @@ -91,28 +95,28 @@ class _ShareUploadViewState extends State { children: [ IconButton( icon: Icon( - provider.filesSortAscending + files.filesSortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: provider.toggleFilesSortOrder, + tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', + onPressed: files.toggleFilesSortOrder, ), SizedBox( width: 130, child: SortMenuButton( - field: provider.filesSortField, - onChanged: provider.setFilesSortField, + field: files.filesSortField, + onChanged: files.setFilesSortField, ), ), ToggleIconButton( - icon: provider.showHiddenFiles + icon: files.showHiddenFiles ? Icons.visibility_rounded : Icons.visibility_off_rounded, - isSelected: provider.showHiddenFiles, - onTap: () => provider.toggleShowHiddenFiles(), + isSelected: files.showHiddenFiles, + onTap: () => files.toggleShowHiddenFiles(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), @@ -120,14 +124,14 @@ class _ShareUploadViewState extends State { children: [ ToggleIconButton( icon: Symbols.circles_rounded, - isSelected: provider.storageScope == StorageScope.cloud, - onTap: () => provider.setStorageScope(StorageScope.cloud), + isSelected: files.storageScope == StorageScope.cloud, + onTap: () => files.setStorageScope(StorageScope.cloud), tooltip: 'Cloud storage', ), ToggleIconButton( icon: Symbols.hard_drive_rounded, - isSelected: provider.storageScope == StorageScope.external, - onTap: () => provider.setStorageScope(StorageScope.external), + isSelected: files.storageScope == StorageScope.external, + onTap: () => files.setStorageScope(StorageScope.external), tooltip: 'External storage', ), ], @@ -137,24 +141,24 @@ class _ShareUploadViewState extends State { children: [ ToggleIconButton( icon: Icons.select_all_rounded, - isSelected: provider.filesTypeFilter == FilesTypeFilter.all, - onTap: () => provider.setFilesTypeFilter(FilesTypeFilter.all), + isSelected: files.filesTypeFilter == FilesTypeFilter.all, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), tooltip: 'Files & folders', ), ToggleIconButton( icon: Icons.insert_drive_file_outlined, isSelected: - provider.filesTypeFilter == FilesTypeFilter.filesOnly, + files.filesTypeFilter == FilesTypeFilter.filesOnly, onTap: () => - provider.setFilesTypeFilter(FilesTypeFilter.filesOnly), + files.setFilesTypeFilter(FilesTypeFilter.filesOnly), tooltip: 'Files only', ), ToggleIconButton( icon: Icons.folder_outlined, isSelected: - provider.filesTypeFilter == FilesTypeFilter.foldersOnly, + files.filesTypeFilter == FilesTypeFilter.foldersOnly, onTap: () => - provider.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), tooltip: 'Folders only', ), ], @@ -164,14 +168,14 @@ class _ShareUploadViewState extends State { children: [ ToggleIconButton( icon: Icons.view_list_rounded, - isSelected: !provider.isGridView, - onTap: () => provider.setGridView(false), + isSelected: !files.isGridView, + onTap: () => files.setGridView(false), tooltip: 'List view', ), ToggleIconButton( icon: Icons.grid_view_rounded, - isSelected: provider.isGridView, - onTap: () => provider.setGridView(true), + isSelected: files.isGridView, + onTap: () => files.setGridView(true), tooltip: 'Grid view', ), ], @@ -185,13 +189,11 @@ class _ShareUploadViewState extends State { // Only folders are valid upload destinations - files still show (so the // listing matches what the Files tab itself would show for this folder) // but are visually dimmed and inert rather than hidden outright. - Widget _buildListTile( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildListTile(BuildContext context, NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final files = context.read(); + final session = context.watch(); final isFolder = item.isFolder; return Padding( @@ -203,9 +205,7 @@ class _ShareUploadViewState extends State { child: Material( color: colorScheme.surfaceContainerLow, child: InkWell( - onTap: isFolder - ? () => provider.navigateToFolder(item.path) - : null, + onTap: isFolder ? () => files.navigateToFolder(item.path) : null, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -215,7 +215,7 @@ class _ShareUploadViewState extends State { children: [ ItemThumbnail( item: item, - provider: provider, + service: session.service, size: 44, borderRadius: 12, iconSize: 22, @@ -255,13 +255,10 @@ class _ShareUploadViewState extends State { ); } - Widget _buildGridCard( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildGridCard(BuildContext context, NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final files = context.read(); final isFolder = item.isFolder; final iconColor = getIconColor(context, item.type); @@ -272,7 +269,7 @@ class _ShareUploadViewState extends State { borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: isFolder ? () => provider.navigateToFolder(item.path) : null, + onTap: isFolder ? () => files.navigateToFolder(item.path) : null, child: Padding( padding: const EdgeInsets.all(14), child: Column( @@ -326,13 +323,14 @@ class _ShareUploadViewState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); - final hasBreadcrumbs = provider.pathStack.length > 1; - final currentPath = provider.pathStack.last; + final files = context.watch(); + final session = context.watch(); + final hasBreadcrumbs = files.pathStack.length > 1; + final currentPath = files.pathStack.last; final currentLabel = currentPath == '/' ? 'Home' : currentPath.split('/').where((s) => s.isNotEmpty).last; - final items = provider.items; + final items = files.items; // Mirrors FilesView's own controls-row + breadcrumbs sticky header // exactly (padding, heights) so this reads as the same browser, just @@ -342,14 +340,14 @@ class _ShareUploadViewState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildControlsRow(provider), + _buildControlsRow(files), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), SizedBox( height: 32, child: Breadcrumbs( - pathStack: provider.pathStack, - onTap: (index) => provider.navigateToPathIndex(index), + pathStack: files.pathStack, + onTap: (index) => files.navigateToPathIndex(index), ), ), ], @@ -365,7 +363,7 @@ class _ShareUploadViewState extends State { child: controlsColumn, ), ), - if (provider.isLoading) + if (files.isLoading) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), @@ -380,7 +378,7 @@ class _ShareUploadViewState extends State { ), ), ) - else if (provider.isGridView) + else if (files.isGridView) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverGrid( @@ -391,7 +389,7 @@ class _ShareUploadViewState extends State { mainAxisSpacing: 12, ), delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(context, items[index], provider); + return _buildGridCard(context, items[index]); }, childCount: items.length), ), ) @@ -400,7 +398,7 @@ class _ShareUploadViewState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { - return _buildListTile(context, items[index], provider); + return _buildListTile(context, items[index]); }, childCount: items.length), ), ), @@ -411,7 +409,6 @@ class _ShareUploadViewState extends State { return Scaffold( body: SyncedHeaderScaffold( scrollController: _scrollController, - provider: provider, // Same trailing actions as every other tab except "More tabs" - // there's nowhere useful for it to go while mid-upload (jumping to // Trash/Shares/etc. would abandon this destination picker), so the @@ -494,7 +491,7 @@ class _ShareUploadViewState extends State { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () => _uploadHere(provider), + onPressed: () => _uploadHere(session), icon: const Icon(Icons.upload_rounded), label: Text('Upload to $currentLabel'), ), diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index 09b9358..0a2922b 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -3,7 +3,7 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/nextcloud_item.dart'; import '../models/nextcloud_share.dart'; -import '../providers/server_provider.dart'; +import '../providers/shares_controller.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; import '../widgets/synced_header_scaffold.dart'; @@ -60,16 +60,16 @@ class _SharesViewState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final sharesController = context.watch(); if (!_requested) { _requested = true; WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.fetchShares(), + (_) => sharesController.fetchAll(), ); } - final shares = provider.shares; + final shares = sharesController.shares; final List contentSlivers = [ SliverToBoxAdapter( @@ -80,19 +80,20 @@ class _SharesViewState extends State { ButtonSegment(value: false, label: Text('Shared by me')), ButtonSegment(value: true, label: Text('Shared with me')), ], - selected: {provider.sharesWithMe}, - onSelectionChanged: (set) => provider.setSharesWithMe(set.first), + selected: {sharesController.sharedWithMe}, + onSelectionChanged: (set) => + sharesController.setSharedWithMe(set.first), ), ), ), const SliverToBoxAdapter(child: SizedBox(height: 8)), - if (provider.isSharesLoading && shares.isEmpty) + if (sharesController.isLoading && shares.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (provider.sharesErrorMessage != null) + else if (sharesController.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -116,7 +117,7 @@ class _SharesViewState extends State { ), const SizedBox(height: 8), Text( - provider.sharesErrorMessage!, + sharesController.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -124,7 +125,7 @@ class _SharesViewState extends State { ), const SizedBox(height: 20), FilledButton.icon( - onPressed: provider.fetchShares, + onPressed: sharesController.fetchAll, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), @@ -147,7 +148,7 @@ class _SharesViewState extends State { ), const SizedBox(height: 12), Text( - provider.sharesWithMe + sharesController.sharedWithMe ? 'Nothing has been shared with you' : 'You haven\'t shared anything yet', style: theme.textTheme.titleMedium?.copyWith( @@ -164,7 +165,7 @@ class _SharesViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final share = shares[index]; - return _buildShareTile(context, share, provider); + return _buildShareTile(context, share); }, childCount: shares.length), ), ), @@ -175,18 +176,13 @@ class _SharesViewState extends State { return SyncedHeaderScaffold( scrollController: widget.scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: provider.fetchShares, + onRefresh: sharesController.fetchAll, contentSlivers: contentSlivers, ); } - Widget _buildShareTile( - BuildContext context, - NextcloudShare share, - ServerProvider provider, - ) { + Widget _buildShareTile(BuildContext context, NextcloudShare share) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final iconColor = colorScheme.primary; @@ -260,7 +256,7 @@ class _SharesViewState extends State { IconButton( icon: Icon(Icons.link_off_rounded, color: colorScheme.error), tooltip: share.sharedWithMe ? 'Remove' : 'Unshare', - onPressed: () => _confirmUnshare(context, provider, share), + onPressed: () => _confirmUnshare(context, share), ), ], ), @@ -271,7 +267,6 @@ class _SharesViewState extends State { Future _confirmUnshare( BuildContext context, - ServerProvider provider, NextcloudShare share, ) async { final confirmed = await showDialog( @@ -303,7 +298,9 @@ class _SharesViewState extends State { if (confirmed != true || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); - final success = await provider.deleteShare(share); + final success = await context.read().deleteShare( + share, + ); messenger.showSnackBar( SnackBar( content: Text( diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 77fb04b..4190504 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../providers/trash_controller.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; import '../widgets/synced_header_scaffold.dart'; @@ -42,26 +42,26 @@ class _TrashViewState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final trashController = context.watch(); if (!_requested) { _requested = true; WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.fetchTrash(), + (_) => trashController.fetchAll(), ); } - final trash = provider.trashItems; + final trash = trashController.items; final List contentSlivers = [ const SliverToBoxAdapter(child: SizedBox(height: 16)), - if (provider.isTrashLoading && trash.isEmpty) + if (trashController.isLoading && trash.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (provider.trashErrorMessage != null) + else if (trashController.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -85,7 +85,7 @@ class _TrashViewState extends State { ), const SizedBox(height: 8), Text( - provider.trashErrorMessage!, + trashController.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -93,7 +93,7 @@ class _TrashViewState extends State { ), const SizedBox(height: 20), FilledButton.icon( - onPressed: provider.fetchTrash, + onPressed: trashController.fetchAll, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), @@ -131,7 +131,7 @@ class _TrashViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = trash[index]; - return _buildTrashTile(context, item, provider); + return _buildTrashTile(context, item); }, childCount: trash.length), ), ), @@ -142,18 +142,13 @@ class _TrashViewState extends State { return SyncedHeaderScaffold( scrollController: widget.scrollController, - provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: provider.fetchTrash, + onRefresh: trashController.fetchAll, contentSlivers: contentSlivers, ); } - Widget _buildTrashTile( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { + Widget _buildTrashTile(BuildContext context, NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final iconColor = colorScheme.outline; @@ -210,7 +205,7 @@ class _TrashViewState extends State { icon: const Icon(Icons.restore_rounded), tooltip: 'Restore', color: colorScheme.primary, - onPressed: () => _restore(context, provider, item), + onPressed: () => _restore(context, item), ), IconButton( icon: Icon( @@ -218,7 +213,7 @@ class _TrashViewState extends State { color: colorScheme.error, ), tooltip: 'Delete forever', - onPressed: () => _confirmDeleteForever(context, provider, item), + onPressed: () => _confirmDeleteForever(context, item), ), ], ), @@ -227,13 +222,9 @@ class _TrashViewState extends State { ); } - Future _restore( - BuildContext context, - ServerProvider provider, - NextcloudItem item, - ) async { + Future _restore(BuildContext context, NextcloudItem item) async { final messenger = ScaffoldMessenger.of(context); - final success = await provider.restoreTrashItem(item); + final success = await context.read().restore(item); messenger.showSnackBar( SnackBar( content: Text( @@ -246,7 +237,6 @@ class _TrashViewState extends State { Future _confirmDeleteForever( BuildContext context, - ServerProvider provider, NextcloudItem item, ) async { final confirmed = await showDialog( @@ -276,7 +266,7 @@ class _TrashViewState extends State { if (confirmed != true || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); - final success = await provider.deleteTrashItemForever(item); + final success = await context.read().deleteForever(item); messenger.showSnackBar( SnackBar( content: Text( diff --git a/lib/widgets/details/details_activity_tab.dart b/lib/widgets/details/details_activity_tab.dart index 1238752..1a9f70e 100644 --- a/lib/widgets/details/details_activity_tab.dart +++ b/lib/widgets/details/details_activity_tab.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../../models/nextcloud_item.dart'; -import '../../providers/server_provider.dart'; +import '../../providers/item_operations.dart'; /// Per-file activity feed — same list-item look as the account-wide /// Activity tab (activity_view.dart), scoped to a single item. @@ -21,8 +21,8 @@ class _DetailsActivityTabState extends State { List _activities = []; Future _load() async { - final provider = context.read(); - final result = await provider.fetchFileActivity(widget.item); + final ops = context.read(); + final result = await ops.fetchFileActivity(widget.item); if (!mounted) return; setState(() { _activities = result; diff --git a/lib/widgets/details/details_versions_tab.dart b/lib/widgets/details/details_versions_tab.dart index c276d82..2881ec6 100644 --- a/lib/widgets/details/details_versions_tab.dart +++ b/lib/widgets/details/details_versions_tab.dart @@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import '../../models/nextcloud_file_version.dart'; import '../../models/nextcloud_item.dart'; -import '../../providers/server_provider.dart'; +import '../../providers/item_operations.dart'; import '../synced_header_scaffold.dart' show formatBytes; /// File version history — a synthetic "Current version" row (from the @@ -27,8 +27,8 @@ class _DetailsVersionsTabState extends State { List _versions = []; Future _load() async { - final provider = context.read(); - final result = await provider.fetchFileVersions(widget.item); + final ops = context.read(); + final result = await ops.fetchFileVersions(widget.item); if (!mounted) return; setState(() { _versions = result; @@ -37,9 +37,9 @@ class _DetailsVersionsTabState extends State { } Future _restore(NextcloudFileVersion version) async { - final provider = context.read(); + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); - final success = await provider.restoreFileVersion( + final success = await ops.restoreFileVersion( widget.item, version.versionLabel, ); @@ -56,7 +56,7 @@ class _DetailsVersionsTabState extends State { } Future _download(NextcloudFileVersion version) async { - final provider = context.read(); + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); try { final tempDir = await getTemporaryDirectory(); @@ -64,7 +64,7 @@ class _DetailsVersionsTabState extends State { tempDir.path, '${version.versionLabel}_${widget.item.name}', ); - final success = await provider.downloadVersion( + final success = await ops.downloadVersion( widget.item, version.versionLabel, tempPath, diff --git a/lib/widgets/item_icon.dart b/lib/widgets/item_icon.dart index 44e466a..a8ea08f 100644 --- a/lib/widgets/item_icon.dart +++ b/lib/widgets/item_icon.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../models/nextcloud_item.dart'; -import '../providers/server_provider.dart'; +import '../services/nextcloud_service.dart'; /// The icon/color/thumbnail treatment for a file or folder, shared by any /// screen that lists [NextcloudItem]s the same way the Files tab does (see @@ -51,7 +51,7 @@ Color getIconColor(BuildContext context, NextcloudItemType type) { /// the Photos tab. class ItemThumbnail extends StatelessWidget { final NextcloudItem item; - final ServerProvider provider; + final NextcloudService? service; final double size; final double borderRadius; final double iconSize; @@ -59,7 +59,7 @@ class ItemThumbnail extends StatelessWidget { const ItemThumbnail({ super.key, required this.item, - required this.provider, + required this.service, required this.size, required this.borderRadius, required this.iconSize, @@ -92,7 +92,7 @@ class ItemThumbnail extends StatelessWidget { children: [ Image.network( item.previewUrl!, - headers: provider.service?.authHeaders, + headers: service?.authHeaders, fit: BoxFit.cover, cacheWidth: cachePixels, cacheHeight: cachePixels, diff --git a/lib/widgets/more_tabs_button.dart b/lib/widgets/more_tabs_button.dart index 57d9755..9832b5a 100644 --- a/lib/widgets/more_tabs_button.dart +++ b/lib/widgets/more_tabs_button.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; -import '../providers/server_provider.dart'; +import '../providers/pick_controller.dart'; +import '../providers/settings_controller.dart'; import 'app_tab_view_builder.dart'; /// Opens a tab that's currently hidden from the bottom nav bar as its own @@ -13,23 +14,24 @@ class MoreTabsButton extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); + final pick = context.watch(); + final settings = context.watch(); // 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 (provider.isPicking) return const SizedBox.shrink(); + if (pick.isPicking) return const SizedBox.shrink(); return PopupMenuButton( icon: const Icon(Icons.apps_rounded), tooltip: 'More tabs', - enabled: provider.hiddenTabs.isNotEmpty, + enabled: settings.hiddenTabs.isNotEmpty, onSelected: (tab) => _openHiddenTab(context, tab), itemBuilder: (context) => [ - for (final tab in provider.tabOrder.where( - (t) => provider.hiddenTabs.contains(t), + for (final tab in settings.tabOrder.where( + (t) => settings.hiddenTabs.contains(t), )) PopupMenuItem( value: tab, diff --git a/lib/widgets/move_copy_conflict_sheet.dart b/lib/widgets/move_copy_conflict_sheet.dart index 366cd94..4ff5a23 100644 --- a/lib/widgets/move_copy_conflict_sheet.dart +++ b/lib/widgets/move_copy_conflict_sheet.dart @@ -7,7 +7,7 @@ import 'gradual_bottom_sheet.dart'; /// destination) - summarizes every conflict at once rather than prompting /// per item as they're hit, and lets the user resolve them all the same /// way (overwrite/keep both) or open a per-item breakdown. Returns the -/// `ConflictChoice` map `ServerProvider.resolveConflicts` expects, or null +/// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null /// if dismissed without choosing. class MoveCopyConflictSheet extends StatefulWidget { final List conflicts; diff --git a/lib/widgets/profile_avatar_button.dart b/lib/widgets/profile_avatar_button.dart index 3cbbeaa..f5e44c9 100644 --- a/lib/widgets/profile_avatar_button.dart +++ b/lib/widgets/profile_avatar_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../providers/server_provider.dart'; +import '../providers/session_controller.dart'; import '../views/account_view.dart'; /// Top-bar avatar button that opens the account screen. Replaces the old @@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget { final velocity = details.primaryVelocity ?? 0; if (velocity.abs() < 250) return; - final provider = context.read(); + final provider = context.read(); final target = velocity < 0 ? await provider.cycleToNextAccount() : await provider.cycleToPreviousAccount(); @@ -37,7 +37,7 @@ class ProfileAvatarButton extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); + final provider = context.watch(); final colorScheme = Theme.of(context).colorScheme; final initial = provider.username.isNotEmpty ? provider.username[0].toUpperCase() diff --git a/lib/widgets/seek_bar_painter.dart b/lib/widgets/seek_bar_painter.dart index a2fc6ed..478902e 100644 --- a/lib/widgets/seek_bar_painter.dart +++ b/lib/widgets/seek_bar_painter.dart @@ -1,6 +1,6 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import '../providers/server_provider.dart'; +import '../providers/settings_controller.dart'; /// Draws a video seek bar in one of the four [MediaProgressBarStyle] /// presets. Shared by the actual in-player seek bar and the small previews diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index 0bba249..8109e25 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -7,7 +7,9 @@ import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; import '../models/nextcloud_share.dart'; import '../models/nextcloud_sharee.dart'; -import '../providers/server_provider.dart'; +import '../providers/item_operations.dart'; +import '../providers/session_controller.dart'; +import '../providers/sync_status_controller.dart'; import 'details/details_sheet.dart' show DetailsHeader; import 'gradual_bottom_sheet.dart'; @@ -83,10 +85,10 @@ class _ShareSheetState extends State { } Future _load() async { - final provider = context.read(); + final ops = context.read(); final results = await Future.wait([ - provider.fetchItemShares(widget.item), - provider.fetchInheritedShares(widget.item), + ops.fetchItemShares(widget.item), + ops.fetchInheritedShares(widget.item), ]); if (!mounted) return; setState(() { @@ -103,8 +105,8 @@ class _ShareSheetState extends State { return; } setState(() => _isSearching = true); - final provider = context.read(); - final results = await provider.searchSharees(trimmed); + final ops = context.read(); + final results = await ops.searchSharees(trimmed); if (!mounted) return; setState(() { _searchResults = results; @@ -113,9 +115,9 @@ class _ShareSheetState extends State { } Future _addSharee(NextcloudSharee sharee) async { - final provider = context.read(); + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); - final share = await provider.createShare( + final share = await ops.createShare( path: widget.item.path, shareType: sharee.shareTypeValue, shareWith: sharee.shareWith, @@ -147,9 +149,9 @@ class _ShareSheetState extends State { final email = _emailController.text.trim(); if (email.isEmpty) return; setState(() => _isAddingEmail = true); - final provider = context.read(); + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); - final share = await provider.createShare( + final share = await ops.createShare( path: widget.item.path, shareType: 4, shareWith: email, @@ -179,9 +181,9 @@ class _ShareSheetState extends State { } Future _createPublicLink() async { - final provider = context.read(); + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); - final share = await provider.createShare( + final share = await ops.createShare( path: widget.item.path, shareType: 3, ); @@ -199,9 +201,9 @@ class _ShareSheetState extends State { } Future _removeShare(NextcloudShare share) async { - final provider = context.read(); + final ops = context.read(); final messenger = ScaffoldMessenger.of(context); - final success = await provider.deleteShare(share); + final success = await ops.deleteShare(share); if (!mounted) return; if (success) { setState(() => _shares = _shares.where((s) => s.id != share.id).toList()); @@ -231,20 +233,21 @@ class _ShareSheetState extends State { /// users/links) and the only one here that reads the file's actual /// bytes. Future _shareFileDirectly() async { - final provider = context.read(); - if (provider.service == null) return; + final session = context.read(); + final sync = context.read(); + if (session.service == null) return; setState(() => _isSharingFile = true); final messenger = ScaffoldMessenger.of(context); try { // Already mirrored locally by device sync? Share that copy straight // away instead of a fresh WebDAV fetch - see - // ServerProvider.localSyncedFilePath. + // SyncStatusController.localSyncedFilePath. final tempPath = - await provider.localSyncedFilePath(widget.item) ?? + await sync.localSyncedFilePath(widget.item) ?? await () async { final tempDir = await getTemporaryDirectory(); final path = p.join(tempDir.path, widget.item.name); - await provider.service!.downloadToFile(widget.item.path, path); + await session.service!.downloadToFile(widget.item.path, path); return path; }(); if (!mounted) return; @@ -334,7 +337,7 @@ class _ShareSheetState extends State { } final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final provider = context.watch(); + final session = context.watch(); if (_isLoading) { return const Center(child: CircularProgressIndicator()); @@ -352,7 +355,7 @@ class _ShareSheetState extends State { final emailShares = _shares .where((s) => s.shareType == ShareType.email) .toList(); - final internalLink = internalLinkFor(provider.serverUrl, widget.item.id); + final internalLink = internalLinkFor(session.serverUrl, widget.item.id); return ListView( controller: widget.scrollController, diff --git a/lib/widgets/sort_menu_button.dart b/lib/widgets/sort_menu_button.dart index da889f6..b8934e3 100644 --- a/lib/widgets/sort_menu_button.dart +++ b/lib/widgets/sort_menu_button.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../providers/server_provider.dart'; +import '../providers/files_controller.dart'; String sortFieldLabel(FileSortField field) { switch (field) { diff --git a/lib/widgets/swipeable_item.dart b/lib/widgets/swipeable_item.dart index 40caca1..6f04ddc 100644 --- a/lib/widgets/swipeable_item.dart +++ b/lib/widgets/swipeable_item.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../providers/server_provider.dart'; +import '../providers/settings_controller.dart'; /// Wraps [child] with the user's configured swipe-left/swipe-right actions /// (favorite, delete, share, or none) — shared between the Files list view @@ -10,7 +10,7 @@ import '../providers/server_provider.dart'; class SwipeableItem extends StatelessWidget { final Key itemKey; final String itemName; - final ServerProvider provider; + final SettingsController settings; final Future Function() onFavorite; final Future Function() onShare; final Future Function() onDelete; @@ -20,7 +20,7 @@ class SwipeableItem extends StatelessWidget { super.key, required this.itemKey, required this.itemName, - required this.provider, + required this.settings, required this.onFavorite, required this.onShare, required this.onDelete, @@ -69,8 +69,8 @@ class SwipeableItem extends StatelessWidget { @override Widget build(BuildContext context) { - final rightAction = provider.swipeRightAction; - final leftAction = provider.swipeLeftAction; + final rightAction = settings.swipeRightAction; + final leftAction = settings.swipeLeftAction; final direction = _directionFor(leftAction, rightAction); if (direction == DismissDirection.none) return child; diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart index 7ef2695..b6045ab 100644 --- a/lib/widgets/synced_header_scaffold.dart +++ b/lib/widgets/synced_header_scaffold.dart @@ -1,11 +1,14 @@ 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/server_provider.dart'; +import '../providers/files_controller.dart'; +import '../providers/session_controller.dart'; +import '../providers/sync_status_controller.dart'; /// Icon/label for the persistent header chip/panel - reflects device-sync -/// status (see `ServerProvider.syncHeaderStatus`), not the WebDAV +/// 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`). @@ -42,7 +45,6 @@ String formatQuota(NextcloudUserQuota quota) { /// holds the tab's own content slivers. class SyncedHeaderScaffold extends StatefulWidget { final ScrollController scrollController; - final ServerProvider provider; /// Trailing icons in the app bar (e.g. an upload button, the avatar). final List actions; @@ -53,8 +55,8 @@ class SyncedHeaderScaffold extends StatefulWidget { final List contentSlivers; /// What a deliberate pull-to-sync should trigger. Defaults to - /// [ServerProvider.refreshData] (the current folder's listing); pass e.g. - /// `provider.fetchTrash` for a tab backed by different data. + /// [FilesController.refreshData] (the current folder's listing); pass + /// e.g. `trashController.fetchAll` for a tab backed by different data. final Future Function()? onRefresh; /// When non-null (a tab is mid-selection), this takes over the pinned top @@ -69,7 +71,6 @@ class SyncedHeaderScaffold extends StatefulWidget { const SyncedHeaderScaffold({ super.key, required this.scrollController, - required this.provider, required this.actions, required this.contentSlivers, this.onRefresh, @@ -118,7 +119,8 @@ class _SyncedHeaderScaffoldState extends State { } void _startRefresh() { - final refresh = widget.onRefresh ?? widget.provider.refreshData; + final refresh = + widget.onRefresh ?? context.read().refreshData; setState(() => _isRefreshing = true); refresh().whenComplete(() { if (mounted) setState(() => _isRefreshing = false); @@ -163,7 +165,7 @@ class _SyncedHeaderScaffoldState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final provider = widget.provider; + final syncStatus = context.watch(); final Widget leadingWidget; if (_headerLocked) { @@ -175,7 +177,7 @@ class _SyncedHeaderScaffoldState extends State { } else if (_isPulling) { leadingWidget = const SizedBox.shrink(); } else { - leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen); + leadingWidget = _SyncStatusChip(onTap: _lockOpen); } final topInset = MediaQuery.of(context).padding.top; @@ -205,7 +207,7 @@ class _SyncedHeaderScaffoldState extends State { expandedHeight: widget.selectionBar != null ? kToolbarHeight : (_headerLocked - ? 190.0 + provider.syncConflicts.length * 52.0 + ? 190.0 + syncStatus.syncConflicts.length * 52.0 : kToolbarHeight), collapsedHeight: kToolbarHeight, backgroundColor: colorScheme.surfaceContainer, @@ -225,7 +227,6 @@ class _SyncedHeaderScaffoldState extends State { ? null : FlexibleSpaceBar( background: _SyncedStretchPanel( - provider: provider, forceVisible: _headerLocked, ), ), @@ -287,16 +288,15 @@ class _SyncedHeaderScaffoldState extends State { /// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling /// the header down to expand that same "Synced" view. class _SyncStatusChip extends StatelessWidget { - final ServerProvider provider; final VoidCallback onTap; - const _SyncStatusChip({required this.provider, required this.onTap}); + const _SyncStatusChip({required this.onTap}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final status = provider.syncHeaderStatus; + final status = context.watch().syncHeaderStatus; final (icon, label) = _syncHeaderDisplay(status); final iconColor = status == SyncHeaderStatus.alert ? colorScheme.error @@ -337,36 +337,33 @@ class _SyncStatusChip extends StatelessWidget { /// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays /// locked open. class _SyncedStretchPanel extends StatelessWidget { - final ServerProvider provider; - /// Keeps the panel fully shown even without live overscroll — used once /// the header has "locked" open after a deliberate pull-down. final bool forceVisible; - const _SyncedStretchPanel({ - required this.provider, - this.forceVisible = false, - }); + const _SyncedStretchPanel({this.forceVisible = false}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final topInset = MediaQuery.of(context).padding.top; + final quota = context.watch().quota; + final serverUrl = context.watch().serverUrl; + final syncStatus = context.watch(); return LayoutBuilder( builder: (context, constraints) { - final settings = context + final spacerSettings = context .dependOnInheritedWidgetOfExactType(); - final maxExtent = settings?.maxExtent ?? constraints.maxHeight; + final maxExtent = spacerSettings?.maxExtent ?? constraints.maxHeight; final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0); // 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 quota = provider.quota; - final status = provider.syncHeaderStatus; + final status = syncStatus.syncHeaderStatus; final (_, statusLabel) = _syncHeaderDisplay(status); - final conflicts = provider.syncConflicts; + final conflicts = syncStatus.syncConflicts; return Stack( children: [ @@ -389,13 +386,10 @@ class _SyncedStretchPanel extends StatelessWidget { fontWeight: FontWeight.w700, ), ), - if (provider.serverUrl.isNotEmpty) ...[ + if (serverUrl.isNotEmpty) ...[ const SizedBox(height: 2), Text( - provider.serverUrl.replaceFirst( - RegExp(r'^https?://'), - '', - ), + serverUrl.replaceFirst(RegExp(r'^https?://'), ''), maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( @@ -438,10 +432,7 @@ class _SyncedStretchPanel extends StatelessWidget { for (final conflict in conflicts) Padding( padding: const EdgeInsets.only(bottom: 8), - child: _SyncConflictRow( - conflict: conflict, - provider: provider, - ), + child: _SyncConflictRow(conflict: conflict), ), ], ], @@ -462,9 +453,8 @@ class _SyncedStretchPanel extends StatelessWidget { /// side), just triggered in-app instead. class _SyncConflictRow extends StatefulWidget { final SyncConflictInfo conflict; - final ServerProvider provider; - const _SyncConflictRow({required this.conflict, required this.provider}); + const _SyncConflictRow({required this.conflict}); @override State<_SyncConflictRow> createState() => _SyncConflictRowState(); @@ -476,7 +466,7 @@ class _SyncConflictRowState extends State<_SyncConflictRow> { Future _resolve(bool useLocal) async { setState(() => _resolving = true); try { - await widget.provider.resolveSyncConflict( + await context.read().resolveSyncConflict( widget.conflict, useLocal: useLocal, ); diff --git a/test/widget_test.dart b/test/widget_test.dart index b977d71..0aa914d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,7 +4,17 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:noo/main.dart'; -import 'package:noo/providers/server_provider.dart'; +import 'package:noo/providers/favorites_controller.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/providers/item_operations.dart'; +import 'package:noo/providers/photos_controller.dart'; +import 'package:noo/providers/pick_controller.dart'; +import 'package:noo/providers/recent_controller.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/providers/shares_controller.dart'; +import 'package:noo/providers/sync_status_controller.dart'; +import 'package:noo/providers/trash_controller.dart'; void main() { const secureStorageChannel = MethodChannel( @@ -21,20 +31,55 @@ void main() { }); }); - test('ServerProvider initializes unauthenticated', () { - final provider = ServerProvider(); - expect(provider.isLoggedIn, false); - expect(provider.items.isEmpty, true); + test('SessionController initializes unauthenticated', () { + final session = SessionController(); + expect(session.isLoggedIn, false); }); testWidgets('App renders LoginView when not logged in', ( WidgetTester tester, ) async { - final provider = ServerProvider(); - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: provider, + MultiProvider( + // Mirrors main.dart's own provider order/wiring. + providers: [ + ChangeNotifierProvider(create: (_) => SessionController()), + ChangeNotifierProvider(create: (_) => SettingsController()), + ChangeNotifierProvider( + create: (context) => SyncStatusController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => FilesController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => + PhotosController(context.read(), context.read()), + ), + ChangeNotifierProvider( + create: (context) => + FavoritesController(context.read(), context.read()), + ), + ChangeNotifierProvider( + create: (context) => TrashController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => SharesController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => RecentController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => PickController(context.read()), + ), + Provider( + create: (context) => ItemOperations( + session: context.read(), + files: context.read(), + photos: context.read(), + favorites: context.read(), + ), + ), + ], child: const NextcloudApp(), ), );