4 Commits
Author SHA1 Message Date
ayushyaandClaude Sonnet 5 15eb8d3c74 Update app icon
Build APK / build (push) Successful in 5m29s
New launcher icon from IconKitchen: adaptive icon layers now live in
mipmap-* (already padded for the safe zone, so the 16% inset wrapper is
gone) and the old drawable-* launcher layers are removed. assets/icon
sources updated to match; app_icon.png is the 512px Play Store icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 19:22:32 -04:00
ayushyaandClaude Sonnet 5 f2bba2914e Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s
- Offline tab is now FilesView(offline: true) over a FolderBrowser interface
  (FilesController / OfflineController) sharing controls, tiles, thumbnails
- Synced files follow the Files Cache rule (default: refresh every 15 min):
  per-account WorkManager jobs, in-app timer, resume and pull triggers;
  root-etag shortcut skips full walks when nothing changed
- Single sync at a time (shared lock); logging out stops that account's sync
- Sync safety: PROPFIND failures skip the path instead of deleting local files
- Mirror empty folders and remove deleted ones; drop synced paths deleted on
  the server; missing local files are re-downloaded
- Notifications: silent per-account sync notifications, "Background sync
  notifications" setting, audible upload/download completion
- Files refresh in place (no spinner flash); retry after network returns
- Fix sync badges not updating (shared native status stream), "Sync off" on
  launch (eager providers), Files Cache section moved under Device Sync

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:06:55 -04:00
ayushyaandClaude Sonnet 5 49ae712f5e Fix device sync: folder status badges, remove-deletes-local, multi-select, header summary
Build APK / build (push) Successful in 5m35s
- Folders now show the synced status badge themselves, not just their
  nested files - device-sync state never tracked folders individually, so
  syncStatusFor derives a folder's badge from whether its path falls
  within sync scope instead.
- Turning sync off for a path now deletes its local mirror and clears its
  native sync-state entries (SyncEngine.removeLocalSync), instead of just
  stopping future updates and leaving the downloaded copy behind.
- "Sync to device" in Files' selection toolbar now works over the whole
  selection at once instead of being gated to a single selected item.
- The sync header's expanded panel shows real folder/item counts instead
  of a static "Synced" label that stayed accurate-sounding even before
  anything had actually synced.
- Confirmation SnackBars on starting/stopping sync from both the
  selection toolbar and Settings' Device Sync card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:20:14 -04:00
ayushyaandClaude Sonnet 5 ff5f04e9a1 Dedupe native transfer/notification code and split ServerProvider into focused controllers
Build APK / build (push) Successful in 5m20s
Consolidates duplicated GET/PUT/notification-channel logic across
DownloadService/ShareUploadService/SyncEngine into shared Kotlin helpers,
gives upload/download real batch queueing instead of dropping a second
concurrent batch, and dedupes repeated Dart channel-argument boilerplate.

Replaces the 2300+ line ServerProvider god object with ten focused
ChangeNotifiers (SessionController, SettingsController, FilesController,
PhotosController, FavoritesController, TrashController, SharesController,
RecentController, SyncStatusController, PickController) plus ItemOperations,
a plain coordinator for cross-domain item mutations - fixing the coupling
where device-sync status, per-tab data, and global UI prefs all lived in
one object. Updates every view/widget call site accordingly and refreshes
the architecture/server/standards/styling docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:04:12 -04:00
113 changed files with 7248 additions and 4078 deletions
+149 -70
View File
@@ -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,162 @@ 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<VoidCallback>`) 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. `refreshData` refreshes **in place** when a listing is
already on screen (pull-to-refresh, periodic refresh, post-mutation): no
spinner, the list is swapped once the server answers, and a failed
refresh keeps the old list. The spinner/error page only appear when there
is nothing to show (first load, `_navigateTo` clears the outgoing folder
first, retry after an error). Keep that invariant - blanking the list to
a spinner made refreshes look like files vanishing and reappearing.
- [`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. Depends on `FilesController` (declared before it in
`main()`) because synced files refresh by the Files Cache rule - it owns
the foreground timer/resume/pull triggers and background scheduling; see
`server.md`'s "Keeping synced files current automatically".
- [`PickController`](../../lib/providers/pick_controller.dart) — "being
picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/
`itemMatchesPickFilter`) — see `server.md`.
- [`FolderBrowser`](../../lib/providers/folder_browser.dart) — the small
interface (`pathStack`/`currentFolderPath`/`items`/`isLoading`/
`errorMessage`/navigation/`reload`) `FilesView` needs from whatever
supplies its folder. `FilesController` (server) and `OfflineController`
(local mirror) both implement it, which is what lets the Offline tab be
`FilesView(offline: true)` - literally the Files tab over the device-sync
mirror, not a second view. `offline` only swaps the data source, reads
images from the local file (`localFileFor`) instead of a server preview,
and turns off what needs the server: selection and bulk actions, swipe
actions, sync badges, "+" (replaced by "Manage synced folders"), pick
mode. The two `FilesView`s are keyed in `buildAppTabView` so the
`IndexedStack` never hands one's State to the other.
- [`OfflineController`](../../lib/providers/offline_controller.dart) — the
Files tab's folder listing read from local storage instead of the
server. Owns no display prefs: `items` runs the
listing through `FilesController.applyFilesDisplayPrefs` (hidden files,
files/folders filter, per-folder sort keyed by the same remote path,
minus the cloud/external scope) and `FilesView` reads grid/list from
`FilesController`, so both tabs share one set of controls
(`FilesControlsRow`). `FilesController` restores those prefs on the
account-*ready* event too (memoized `_restoreDisplayPrefs`) so they apply
offline. Refreshed (see `server.md`'s "Device sync" section) automatically whenever a sync pass
finishes. Listens for `SessionController.addAccountReadyListener`, not
`addAccountActivatedListener` - its work is local-only, so it's safe to
run even on a provisional/offline login (see `ConnectivityController`
below). Registered with `lazy: false` in `main.dart` (as is
`SyncStatusController`) - the ready event fires once at startup, before
any widget reads a lazily-created provider, so a lazy one would register
its listener too late and never load its persisted state.
- [`ConnectivityController`](../../lib/providers/connectivity_controller.dart)
— wraps `connectivity_plus`, exposing just `isOffline`. The one thing
every network-touching controller (indirectly, via `SessionController`)
and `MainShellView` consult before making a request or deciding what to
show - see `server.md`'s "Working offline" section.
- [`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_<activeAccountId>_...` 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
`MainShellView` is a bottom-nav `IndexedStack` over up to 8 tabs — Files,
Photos, Favorites, Activity, Trash, Shares, Recent, Offline — 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,11 +198,17 @@ 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
only two that make sense as external "choose a file" sources.
only two that make sense as external "choose a file" sources. The same
override mechanism restricts the visible tabs to just Offline while
`ConnectivityController.isOffline` - see `server.md`'s "Working offline"
section for the full story, including why session restore itself has to
avoid making a network request in that case. `MoreTabsButton` hides
entirely in both cases (picking, offline) for the same reason - there's
nothing it could usefully open.
`MainShellView` also fires a one-time notification-permission prompt on
its first mount (`_maybeRequestNotificationPermission`, gated by a plain
`shared_preferences` flag so it only ever asks once, not on every
@@ -144,7 +223,7 @@ pill's item row in a horizontal `SingleChildScrollView` rather than a
plain `Row`, so a wide selected-item label plus several icon-only tabs
scrolls instead of overflowing on narrower screens.
All six tabs, plus `ShareUploadView` (the share-to-upload destination
All eight tabs, plus `ShareUploadView` (the share-to-upload destination
picker, pushed rather than a tab - see below), share
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
`CustomScrollView` with a pull-down "sync status" header (Google
@@ -170,9 +249,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
+361 -69
View File
@@ -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/<item.id>/`
subfolder, keeping the on-disk filename as plain `item.name` rather than
@@ -272,9 +274,13 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
`oc:fileid` - stable across renames/moves, unlike `path`) to decide, per
file: download (new, or server `etag` changed), upload (local file's
mtime/size changed and the server didn't), delete locally (missing
server-side, unchanged locally), respect a local deletion (file's gone
and the server didn't change either - don't recreate it), or flag a
**conflict** (both changed since the last recorded state).
server-side, unchanged locally), re-download a file whose local copy is
missing even though state says it was mirrored (stale state from an
earlier sync of the path - treating that as "user deleted it" used to
skip the download and report phantom removals), or flag a
**conflict** (both changed since the last recorded state). The
"removed" count in the summary notification only counts local files that
actually existed and were deleted.
- [`SyncWorker.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt)
(`CoroutineWorker`) is both the periodic job and the one-off "Sync now":
for each configured path, `propfindSelf`s it first to check whether it's
@@ -284,7 +290,16 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
came from a walk or a single lookup, so single-file sync needed no engine
changes, just this one branch) - applies `diffFolder`'s decisions, then
posts a summary notification (files updated/uploaded/removed) and, for
any conflicts, one notification per file with two actions.
any conflicts, one notification per file with two actions. A progress
notification (`notifyProgress`) is shown lazily - only once the first
actual download/upload starts, not unconditionally at the top of every
run, so a periodic pass that finds nothing to transfer never flashes a
notification at all. It shares the summary's notification ID
(`SUMMARY_NOTIFICATION_ID`) on purpose: the final `notifySummary` call
naturally replaces the ongoing progress notification in place once the
run finishes (no flicker of two separate notifications), and if nothing
ended up changing, `doWork` explicitly cancels that ID since there's no
summary to replace it with.
- **Live status reaches Dart via a push channel, not polling** -
[`SyncStatusBus.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt)
is a plain in-process pub/sub (no IPC needed - the workers and
@@ -297,21 +312,40 @@ 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 - which is also why `SyncWorker.doWork()` saves state *before*
its final `SyncStatusBus.setSyncing(accountId, false)` publish, not
after: that publish is what tells Dart's live listener to recompute
`syncedFileIds`, so publishing first would hand back a snapshot still
missing every file the run just downloaded, with no further event ever
arriving afterward to correct it - newly-synced files would sit with no
badge until the app restarted and force-refreshed via `getStatus()`.
`SyncService.getStatus(accountId)` (one-shot, seeds `SyncStatusController`
right after login/account-switch - the account id is passed explicitly
because `SyncStatusBus` only learns an account once a sync pass has run
in this process, so on a fresh app start it'd report empty
`syncedFileIds`; `SyncStatusController._applySnapshot` likewise ignores
the bus's initial null-account emission so it can't wipe that seed) 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
- drives the small corner badge on Files' tiles, `SyncStatusBadge`) are
both computed from this state, not fetched per-item.
both computed from this state, not fetched per-item. Folders never get
their own entry in the native state map (`diffFolder` only ever tracks
individual files - `if (entry.isFolder) continue`), so `syncStatusFor`
derives a folder's badge differently than a file's: `synced` once the
folder's own path (or an ancestor of it) is in sync scope
(`_isPathInSyncScope`, shared with `localSyncedFilePath`), `syncing`
while any sync pass is running, `conflict` if a pending conflict's
`remotePath` falls under it - not from `syncedFileIds`, which only ever
contains individual files.
- **In-app conflict resolution reuses the exact same enqueue path as the
notification actions** - `ConflictResolveWorker.enqueue(...)` is a
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
@@ -323,31 +357,187 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
[`ConflictResolveWorker`](../../android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt)
to actually push the local copy up or pull the server copy down and
refresh that file's recorded state.
- **Keeping synced files current automatically - no "Sync now" needed.**
Synced paths follow the Files Cache rule (Settings, right under Device
Sync; default *Refresh periodically, 15 min*), driven by
`SyncStatusController` (see its doc comment): *periodically* = a
WorkManager `PeriodicWorkRequest` at `max(interval, 15)` min (Android's
floor) via `reschedule(intervalMinutes:)`, plus an in-app `Timer` at the
exact interval and a pass on app resume if one is due; *never cache* = no
background job, a pass on each app open/resume; *manual* = only
pull-to-refresh (`SyncedHeaderScaffold` calls `syncOnPull`, on any tab) and
"Sync now". **There's no server push:** Nextcloud's push options
(`notify_push` needs a server app plus a persistent WebSocket, which
Android kills in the background without a foreground service; Nextcloud's
FCM/UnifiedPush proxy needs a registered app identity) aren't viable for
a self-hosted-server client, so it polls - and polling is made cheap by the
**root-etag shortcut**: Nextcloud propagates any descendant change up
through every ancestor folder's `getetag`, so each run first does one
Depth-0 PROPFIND per synced root and skips the recursive walk if the etag
matches the stored `SyncEngine.RootMarker` *and* every local file still
matches its recorded size/mtime (`localMatchesState` - nothing to upload or
re-download). A root is only marked after a fully clean pass (no failed
transfer, no conflict), a full walk is forced at least every 6 h
(`FULL_WALK_MAX_AGE_MS`; etag propagation is unreliable on external
storage), and "Sync now" (`KEY_FORCE`) always walks. Automatic/pull passes
are `force: false`: silent (progress notification only once a transfer
starts), `ExistingWorkPolicy.KEEP` (never cancel a run in progress), and
automatic ones also carry the Wi-Fi-only constraint. **Safety:** PROPFIND
failures (network error, 5xx) throw `RemoteUnavailableException` and the
path is skipped that run - previously they returned an empty listing,
which made every synced file look deleted server-side and the diff deleted
the local copies (a real risk once passes run every few minutes). Only a
clean 404 means "gone".
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
(`reschedule`/`cancel`/`syncNow`) is the only bridge from Dart: a periodic
`WorkRequest`'s input `Data` and `Constraints` are fixed at enqueue time,
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
(`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge
from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are
fixed at enqueue time, 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 - `SyncStatusController` calls `SyncService.reschedule`
after every successful login/account switch and every synced-folder/
cache-rule/`syncOnCellular` change. `reschedule` covers **every saved
account**, not just the active one: each account with sync enabled gets its
own periodic job (`SyncWorker.periodicNameFor(accountId)`) carrying that
account's own credentials (read from `AccountStore`), paths and interval -
the active account's from live `SyncStatusController` state, the others'
from their persisted per-account prefs (`_SyncConfig.fromPrefs`). Logging
out stops that account's background sync: `SessionController.logout`
records it in `AccountStore`'s signed-out set (so a later `reschedule` for
another account doesn't resurrect its job) and cancels it via
`SyncService.cancelAccount`; activating the account again clears the flag.
Removing an account cancels its job too. One-off runs are per-account
(`oneOffNameFor`).
- **Folders, not just files, are mirrored.** After applying a path's diff,
`SyncWorker` calls `SyncEngine.mirrorFolders` (a local directory for every
remote folder, so an empty folder created on the web appears on device) and
`pruneRemovedFolders` (removes local directories the server no longer has,
but only *empty* ones - the diff has already deleted the files that were in
them - and never the sync root). Root-etag markers carry a version
(`ROOTS_VERSION`) so bumping it forces one full walk after a change like this.
A configured path the server 404s (deleted on the web) is recorded via
`setRootMissing`; the snapshot's `missingRoots` lets
`SyncStatusController._applySnapshot` drop it from the synced list.
- **Notifications.** Sync notifications are silent (channel `device_sync_v2`,
`IMPORTANCE_LOW` + `setSilent`), with ids and titles scoped per account
(`summaryNotificationId`/`conflictNotificationId`) so accounts don't
overwrite each other. "Background sync notifications" (Settings, global,
default on) gates progress/summary for automatic runs (`KEY_NOTIFY`,
baked into the periodic job input so it needs a reschedule); conflicts and
user-initiated "Sync now" always notify. Manual download/upload
notifications use `file_downloads_v2`/`share_upload_v2` at default importance
(progress silent, completion audible). Channel importance can't be changed
once created, so changing it means a new channel id plus
`NooNotificationChannels.ensure(legacyIds = ...)` deleting the old one.
- **One sync at a time.** `SyncEngine.syncLock` (a process-wide coroutine
`Mutex`) wraps both `SyncWorker` and `ConflictResolveWorker`. WorkManager
runs differently-named jobs concurrently, and both workers load the whole
sync-state map, mutate it and save it back - so overlapping runs (periodic
+ a pull, two accounts due at once) silently discarded each other's
updates, and `SyncStatusBus` only tracks one account at a time. A run that
has to wait simply starts when the current one finishes. The network constraint is `NetworkType.UNMETERED` by default
(`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's
opted into cellular sync. Turning sync off for a path
(`SyncStatusController.removeSyncedPath`) also calls
`SyncService.removeLocalSync`, which runs `SyncEngine.removeLocalSync` on
a background thread (deletes the local mirror files under that path
*and* their entries in the native sync-state map - clearing state too,
not just the files, matters because a bare "file's gone but the server
hasn't changed" without a state reset reads as a user-initiated local
deletion to `diffFolder`, so a later re-add wouldn't re-download
anything).
- 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
(JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); so does
`syncEverything` (`ui_sync_everything`, per account) - when on,
`SyncService` sends `['/']` as the path list instead of `syncedPaths`,
mirroring the whole account rather than requiring per-item opt-in.
`syncOnCellular` is a plain global pref. All three are managed from
Settings → Device Sync (a "Sync everything" switch, the path list with
remove buttons - hidden while "Sync everything" is on - the cellular
toggle, and a manual "Sync now"); individual files or folders are
additionally toggled from Files' selection toolbar ("Sync to device",
single-selection, either item type).
(JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); a
parallel `_syncedPathTypes` map (path -> isFolder, `ui_synced_folder_types`)
tracks which of those paths are folders vs individual files for the sync
header's folder/item counts, defaulting missing entries to folder (the
common case, and what any path added before this map existed will look
like). **`addSyncedPaths`/`removeSyncedPaths` gate on
`_accountLoadedGate`** (a `Completer`, deliberately starting
*incomplete* - not pre-completed - completed once
`_onAccountActivated`'s async per-account prefs load actually finishes,
re-armed on `_onAccountCleared`) before touching `_syncedPaths` at all -
without this, syncing a folder soon enough after opening the app (or an
account switch) could race that load: the mutator spreads the *current
in-memory* `_syncedPaths` (still `[]`, the pre-load default) and
immediately persists the result, silently overwriting the
previously-saved list and losing every other folder that had been
synced before. Starting the gate pre-completed was an actual bug here -
it meant only account *switches* (which call `_onAccountCleared`,
re-arming it) were protected, leaving the very first cold-start load
completely exposed to the race. A second, related guard,
`_hasLoadedSyncedPathsForAccount`, stops `_onAccountActivated` from
re-reading `_syncedPaths` from storage more than once per account -
activation can fire again for the same account (e.g.
`SessionController` re-verifying a provisional/offline login once
connectivity returns), and a second read could clobber an in-memory
mutation made between the first load and that one if its own persist
hadn't landed yet. `syncEverything`
(`ui_sync_everything`, per account) works the
same way - when on, `SyncService` sends `['/']` as the path list instead
of `syncedPaths`, mirroring the whole account rather than requiring
per-item opt-in. `syncOnCellular` is a plain global pref. `syncEverything`
and `syncOnCellular` are managed from Settings → Device Sync (a "Sync
everything" switch, the cellular toggle, a manual "Sync now", and a
"View offline files" row that pushes the Offline tab - see below); the
configured path list itself, with its remove buttons, lives on the
Offline tab now, not Settings. Individual files or folders are toggled
from Files' selection toolbar ("Sync to device", works over the whole
selection at once - either item type, folders or files - not just a
single item; the action reads as "stop syncing" only once every selected
item is already synced, otherwise it syncs whichever ones aren't yet,
and either direction ends with a confirmation SnackBar). Multi-item
add/remove goes through `SyncStatusController.addSyncedPaths`/
`removeSyncedPaths` (batched), never a per-item loop of
`addSyncedPath`/`removeSyncedPath` - looping was an actual bug: each
`addSyncedPath` call fires its own `SyncService.syncNow`, and
`syncNow`'s native side enqueues via `WorkManager.enqueueUniqueWork(...,
ExistingWorkPolicy.REPLACE, ...)`, so a second item's call cancelled the
first item's still-in-flight sync pass instead of letting it finish -
only ever syncing the last item enqueued. The batched methods mutate
`_syncedPaths` for the whole set and call `reschedule`/`syncNow` exactly
once, with the complete folder list, so `SyncWorker` handles every item
in one run (it already loops its whole `folders` list sequentially
within a single `doWork()` call - see above).
- **The Offline tab** (`FilesView(offline: true)` over
[`OfflineController`](../../lib/providers/offline_controller.dart), see
`architecture.md`'s `FolderBrowser`) is the Files tab itself - same
breadcrumbs, controls, tiles and thumbnails - over whatever device-sync
has actually landed on disk - deliberately no selection/multi-select
toolbar, since delete/share/move don't make sense for an already-synced
local mirror.
Reads straight off `<externalFilesDir>/sync/<accountId>/<currentFolder>`
(non-recursive `Directory.list()` per folder) via `dart:io`, not fetched
from the server (mirrors `SyncEngine.kt#syncRoot`'s layout exactly), so
it works with no connection and never round-trips through a
MethodChannel just to list files. `OfflineController` refetches the
current folder automatically whenever a `SyncService.statusStream`
snapshot shows syncing just stopped, so newly-downloaded files show up
without a manual pull-to-refresh. Tapping an item opens
`FileViewerScreen` with `localPath` set (see below) - same in-app viewer
Files uses, just reading from disk instead of the server; tapping a
folder navigates into it, same as Files. Resolves the local path via
`OfflineController.localPathFor(item)`, a pure function of the item's
path - deliberately *not*
`SyncStatusController.localSyncedFilePath`, which additionally
re-verifies the item falls under a configured sync target
(`_isPathInSyncScope`). That check is redundant and was actually a bug
here: every item this controller ever hands out already came from
listing this exact directory tree, so it's definitionally already
local, and re-deriving "is this still in scope" from `syncedPaths`
could disagree with what's genuinely sitting on disk (e.g. nested
paths, timing right after a scope change) and report a visibly-listed
file as "no longer available". Pull-to-refresh triggers
`SyncService.syncNow` followed by a re-list. The
configured sync targets themselves (with "stop syncing" per target, what
used to be Settings' Device Sync card's own inline list) live in a
bottom sheet behind the app bar's sync icon (`_ManageSyncedFoldersSheet`)
rather than inline in the main view, so the browser itself stays a plain
Files-style listing; Settings' own "View offline files" row just pushes
this whole tab.
- **`android/app/proguard-rules.pro` exists specifically for this feature,
and keeps `androidx.work.**` wholesale rather than naming individual
classes.** Flutter's own Gradle plugin auto-enables R8 minification for
@@ -383,7 +573,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
@@ -391,10 +581,102 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
selected file is already synced - a mixed selection still goes through
the normal `DownloadService` batch) both check it first.
## Working offline
[`ConnectivityController`](../../lib/providers/connectivity_controller.dart)
wraps `connectivity_plus` (OS-level route detection - Wi-Fi/mobile/none,
not a guarantee the Nextcloud server itself is reachable) and is the
single source of truth every offline-aware decision in the app consults.
**Its very first `checkConnectivity()` call is re-verified once more,
~2 seconds later** - that first check can spuriously report "no network"
while Android's connectivity stack is still attaching callbacks to a
just-started process (a real cold-start quirk, not a genuine transition),
and since `onConnectivityChanged` only fires on actual transitions, a
false initial "offline" read would otherwise stick for the rest of the
session with no further event ever correcting it - `SessionController`
would keep treating the login as provisional indefinitely, and every
network-fetching controller (Files/Photos/Favorites/Trash/Shares/Recent)
would simply never receive its real activation signal, leaving every tab
permanently empty despite the device being online the whole time. This
was a real, previously-shipped regression, not a hypothetical.
- **Session restore never makes a doomed HTTP request.**
`SessionController._applyCredentialsForAccount` checks
`connectivity.isOffline` *before* calling `NextcloudService.testConnection()`
- if there's no route at all, it skips the request entirely rather than
letting it fail (fast or slow) and parsing the exception. Either way (no
route, or a network-level exception once a request is attempted -
timeout, DNS, unreachable host, a transient 5xx), the session logs in
*provisionally*: `_isLoggedIn = true` with the already-constructed
`_service` kept around, `_isProvisionalLogin = true`. Only an actual 401
(`_errorMessage` contains "401") is treated as a real rejection - drops
the stored password and logs out for real. Provisional login exists
specifically so a genuinely offline cold start still lands on
`MainShellView` (restricted to the Offline tab - see below) instead of
bouncing to `LoginView`, which would strand the user with no way back in
short of Login Flow v2 again (`switchAccount` no-ops when "switching" to
the account that's already nominally active, and `activeAccountId` is
never cleared by a network failure).
- **Two account-activation signals, not one**, precisely so a provisional
login doesn't cascade into a pile of doomed requests from every other
controller: `addAccountActivatedListener` (fires *only* on a real,
verified login - what `FilesController`/`PhotosController`/
`FavoritesController`/`TrashController`/`SharesController`/
`RecentController` all register for, since their own activation work is
a network fetch) vs. `addAccountReadyListener` (fires on *either* a
verified or a provisional login - what `SyncStatusController`/
`OfflineController` register for instead, since their own activation
work - loading prefs, listing local files, calling the native sync
MethodChannel - is local/native-only and safe with no connection at
all). A provisional login fires only `ready`, never `activated`; a
verified login fires both.
- **Reconnecting re-verifies automatically.** `SessionController` listens
to `connectivity` itself; the moment it flips from offline to online
while `_isProvisionalLogin` is still true, it re-runs
`_applyCredentialsForAccount` with the same cached account/password. On
success this is what finally fires `_notifyAccountActivated` for real,
so Files/Photos/etc. get their first actual fetch without the user
having to force-quit/restart the app.
- **`MainShellView` collapses the bottom nav to just the Offline tab**
while `connectivity.isOffline`, the same override mechanism already used
for picking mode (see `architecture.md`) - every other tab would just
show its own loading spinner or error state with no connection, so
there's nothing useful to switch to. `MoreTabsButton` hides entirely in
this state too, for the same reason it hides while picking - there's no
hidden tab it could usefully open either.
- **The sync header's "Offline" label wins over everything else.**
`_syncHeaderDisplay`/`_syncSummary` (`synced_header_scaffold.dart`) both
take a `bool isOffline` and check it first, ahead of conflicts/syncing/
configured-targets - with no connection, *why* nothing's syncing right
now matters more than what would otherwise be shown, so "Sync off"
(nothing configured) and "Offline" (nothing *can* sync right now,
regardless of configuration) stay distinct messages.
- **`FileViewerScreen` can read a file straight from disk.** Its optional
`localPathResolver` param (`Future<String?> Function(NextcloudItem)`,
only ever set by the Offline tab, passing `OfflineController.
localPathFor`) swaps every preview widget's data source -
`_ImagePreview`/`_VideoPreview` use `Image.file`/
`VideoPlayerController.file` instead of the `.network`/`.networkUrl`
variants, `_PdfPreview`/`_TextPreview` read via
`File(path).readAsBytes()` instead of `NextcloudService.fetchBytes` -
same in-app viewer either way, no separate "offline preview" screen.
Unlike a single up-front path, a *resolver* is what makes `siblings`
(swipe-between-media) behave identically to Files/Photos while offline:
the swipeable `PageView.builder` calls it again for whichever sibling
you've swiped to (wrapped in a `FutureBuilder`, since each resolution
is an async disk check), not just the item the viewer opened on - the
Offline tab passes its whole current folder's `items` as `siblings`,
same as Files does with its own. The action bar hides Favorite/Delete/
Download-to-device (`showServerActions: false`) since those need a live
server - Share and Open-externally still work (`_openExternally` calls
the resolver directly instead of downloading to a temp file first;
Share already prefers a local copy when one exists, see
`ShareSheet._shareFileDirectly`).
## 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 +695,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_<accountId>_<key>`
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 +746,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 +762,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
+34 -23
View File
@@ -29,37 +29,48 @@ 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<ServerProvider>()` in `build`,
and `context.read<ServerProvider>()` for one-off calls from callbacks
(matches `LoginView._handleContinue`).
- Read controller state with `context.watch<XController>()` in `build`, and
`context.read<XController>()` 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
- Widget tests must mock platform channels that the app touches on startup
— `SharedPreferences.setMockInitialValues({})` and a mock
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler —
and disable Google Fonts network fetching
— `SharedPreferences.setMockInitialValues({})`, a mock
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler,
and `connectivity_plus`'s `MethodChannel('dev.fluttercommunity.plus/
connectivity')` (`'check'` → a result list, e.g. `['wifi']`) plus its
`EventChannel('dev.fluttercommunity.plus/connectivity_status')` (a
`MockStreamHandler.inline` with a no-op `onListen` is enough) — 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
+23 -7
View File
@@ -36,6 +36,15 @@ widgets. Key points:
## Reusable chrome
- `MediaGridTile` (`lib/widgets/media_grid_tile.dart`): the full-bleed image/video
grid card with name/size scrim, shared by Files (server preview) and Offline
(local `FileImage`, images only - no video frame-extraction plugin, so offline
videos use the plain icon card). `ItemThumbnail` takes an optional `localFile`
for the same offline-image case in list tiles.
- `FilesControlsRow` (`lib/widgets/files_controls_row.dart`): the
sort/hidden/scope/type-filter/view-mode row, shared by the Files and
Offline tabs (`showStorageScope: false` for Offline).
- [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart)
— the icon/color/thumbnail treatment for a file or folder, shared by any
screen that lists `NextcloudItem`s the way the Files tab does (currently
@@ -57,18 +66,25 @@ 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
the pull-to-sync `CustomScrollView` header shared by all 8 tabs (see
`architecture.md`); also where the pull-to-refresh gesture thresholds and
the classic Material refresh spinner live. Its persistent chip/panel
the classic Material refresh spinner live. Its persistent compact chip
(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.
persistent text/icon over. The expanded panel's headline is a separate,
more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) -
counts of what's actually configured to sync ("2 folders & 1 file
synced") rather than just repeating the chip's generic label.
Deliberately doesn't add up the individual files inside a synced folder
("1 folder synced", not "1 folder & 4 items synced") - once a folder's
synced, its file count is an implementation detail, not something the
user picked.
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small
corner badge over a thumbnail showing per-item device-sync status
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files'
+4
View File
@@ -2,6 +2,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<!-- For local_auth's biometric app-lock (Settings > Security). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<!-- For connectivity_plus (ConnectivityController), which gates the
Offline tab restriction and skips network requests entirely while
offline - see session_controller.dart/main.dart. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- For ShareUploadService/DownloadService, which keep a "Share to Noo"
upload/download going (and cancellable from its notification) even
if the app is fully closed - see each service's own doc comment. -->
@@ -7,6 +7,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
@@ -62,6 +63,11 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
// Same lock as SyncWorker: both load/mutate/save the whole state map.
SyncEngine.syncLock.withLock { performResolve() }
}
private suspend fun performResolve(): Result = withContext(Dispatchers.IO) {
val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure()
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure()
val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure()
@@ -84,7 +90,9 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
// Refresh the recorded state from the server's post-resolution
// etag, so this file isn't immediately re-flagged as a conflict on
// the next sync pass.
val fresh = SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath)
val fresh = runCatching {
SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath)
}.getOrNull()
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
state[fileId] = SyncEngine.FileState(
relPath = relPath,
@@ -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)
}
}
}
}
}
@@ -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 {
@@ -45,13 +42,10 @@ class DownloadService : Service() {
const val EXTRA_USERNAME = "username"
const val EXTRA_AUTH_HEADER = "authHeader"
private const val CHANNEL_ID = "file_downloads"
private const val CHANNEL_ID = "file_downloads_v2"
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<DownloadFile>,
val serverUrl: String,
val username: String,
val authHeader: String,
)
@Volatile
private var cancelled = false
private val queue = TransferQueue<DownloadBatch> { 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,16 @@ class DownloadService : Service() {
return START_NOT_STICKY
}
createNotificationChannel()
NooNotificationChannels.ensure(
this,
CHANNEL_ID,
"File downloads",
// Default (not low) importance so a finished download actually
// alerts; the progress updates themselves stay silent below.
NotificationManager.IMPORTANCE_DEFAULT,
"Progress and completion of files downloaded from Noo",
legacyIds = listOf("file_downloads"),
)
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
@@ -88,16 +103,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 +120,25 @@ class DownloadService : Service() {
}
}
private fun runDownloads(
files: List<DownloadFile>,
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 +153,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 +184,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 +229,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(
@@ -281,6 +253,7 @@ class DownloadService : Service() {
.setContentTitle("Downloading from Noo")
.setContentText(text)
.setOnlyAlertOnce(true)
.setSilent(true)
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
when {
@@ -301,7 +274,7 @@ class DownloadService : Service() {
}
override fun onDestroy() {
cancelled.set(true)
cancelled = true
super.onDestroy()
}
}
@@ -132,10 +132,13 @@ class MainActivity : FlutterFragmentActivity() {
.setMethodCallHandler { call, result ->
when (call.method) {
"reschedule" -> rescheduleSyncWork(call, result)
"cancel" -> cancelSyncWork(result)
"cancel" -> cancelSyncWork(call, result)
"syncNow" -> syncNow(call, result)
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
"getSyncStatus" -> result.success(
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
)
"resolveConflict" -> resolveConflict(call, result)
"removeLocalSync" -> removeLocalSync(call, result)
else -> result.notImplemented()
}
}
@@ -156,16 +159,31 @@ class MainActivity : FlutterFragmentActivity() {
})
}
private fun syncStatusMap(status: SyncStatusBus.Status): Map<String, Any?> {
val syncedFileIds = status.accountId?.let {
/// [accountIdOverride] is the account Dart is asking about - needed
/// because [SyncStatusBus] only learns an account once a sync pass has
/// run in this process, so on a fresh app start its `accountId` is null
/// and the durable per-file synced state (which is what actually says
/// "this file is mirrored") would otherwise be reported as empty.
private fun syncStatusMap(
status: SyncStatusBus.Status,
accountIdOverride: String? = null,
): Map<String, Any?> {
val accountId = accountIdOverride ?: status.accountId
// Live syncing/conflict state on the bus belongs to whichever
// account last synced - don't attribute it to a different one.
val busMatches = status.accountId == null || status.accountId == accountId
val syncedFileIds = accountId?.let {
SyncEngine.loadState(applicationContext, it).keys.toList()
} ?: emptyList()
return mapOf(
"accountId" to status.accountId,
"syncing" to status.syncing,
"syncingFileIds" to status.syncingFileIds.toList(),
"accountId" to accountId,
"syncing" to (busMatches && status.syncing),
"syncingFileIds" to if (busMatches) status.syncingFileIds.toList() else emptyList(),
"syncedFileIds" to syncedFileIds,
"conflicts" to status.conflicts.map {
"missingRoots" to (accountId?.let {
SyncEngine.loadMissingRoots(applicationContext, it).toList()
} ?: emptyList()),
"conflicts" to (if (busMatches) status.conflicts else emptyList()).map {
mapOf(
"accountId" to it.accountId,
"fileId" to it.fileId,
@@ -345,7 +363,7 @@ class MainActivity : FlutterFragmentActivity() {
/// Shared arg-parsing for `reschedule`/`syncNow` - both need the same
/// account/credentials/folder-list shape, just enqueue differently.
private fun syncWorkData(call: MethodCall): androidx.work.Data? {
private fun syncWorkData(call: MethodCall, force: Boolean = false): androidx.work.Data? {
val accountId = call.argument<String>("accountId")
val serverUrl = call.argument<String>("serverUrl")
val username = call.argument<String>("username")
@@ -362,6 +380,8 @@ class MainActivity : FlutterFragmentActivity() {
SyncWorker.KEY_USERNAME to username,
SyncWorker.KEY_AUTH_HEADER to authHeader,
SyncWorker.KEY_FOLDERS to foldersJson,
SyncWorker.KEY_FORCE to force,
SyncWorker.KEY_NOTIFY to (call.argument<Boolean>("notify") ?: true),
)
}
@@ -371,13 +391,26 @@ class MainActivity : FlutterFragmentActivity() {
/// periodic WorkRequest's input Data/constraints are fixed at enqueue
/// time and can only be changed by cancelling and re-enqueueing.
private fun rescheduleSyncWork(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
if (accountId == null) {
result.error("bad_args", "Missing accountId", null)
return
}
val data = syncWorkData(call)
val foldersJson = call.argument<String>("folders")
val wifiOnly = call.argument<Boolean>("wifiOnly") ?: true
// Null = periodic background sync is off (Files Cache set to
// "never"/"manual") - only foreground triggers and pull-to-refresh
// sync then. See SyncStatusController's doc comment.
val intervalMinutes = call.argument<Int>("intervalMinutes")
val workManager = WorkManager.getInstance(this)
// The single shared job from before per-account scheduling.
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0) {
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0 ||
intervalMinutes == null
) {
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
result.success(null)
return
}
@@ -385,39 +418,85 @@ class MainActivity : FlutterFragmentActivity() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
// 1 hour is WorkManager's own practical floor for a "battery-
// friendly" cadence well above its hard 15-minute minimum; there's
// no per-user interval setting for this in v1.
val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
// Follows the Files Cache "refresh periodically" interval. Android
// won't run periodic work more often than every 15 minutes, so
// shorter intervals are clamped up (the app's own in-foreground
// timer covers the shorter cadence while it's open).
val request = PeriodicWorkRequestBuilder<SyncWorker>(
maxOf(intervalMinutes, 15).toLong(),
TimeUnit.MINUTES,
)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
SyncWorker.UNIQUE_PERIODIC_NAME,
SyncWorker.periodicNameFor(accountId),
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
result.success(null)
}
private fun cancelSyncWork(result: MethodChannel.Result) {
WorkManager.getInstance(this).cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
/// Cancels one account's periodic job (e.g. when it's removed).
private fun cancelSyncWork(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
val workManager = WorkManager.getInstance(this)
if (accountId != null) {
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
}
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
result.success(null)
}
/// Deletes a path's local mirror once the user turns sync off for it -
/// see [SyncEngine.removeLocalSync]. Runs off the main thread since it
/// walks/deletes a directory tree; `result.success` is posted back via
/// [mainHandler] since MethodChannel results must be delivered on the
/// platform thread.
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
val path = call.argument<String>("path")
if (accountId == null || path == null) {
result.error("bad_args", "Missing required arguments", null)
return
}
Thread {
SyncEngine.removeLocalSync(applicationContext, accountId, path)
mainHandler.post { result.success(null) }
}.start()
}
/// One-off immediate run (Settings' "Sync now"), independent of the
/// periodic schedule.
private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
val data = syncWorkData(call)
// `force` = an explicit "Sync now" / newly-added path: full walk,
// visible progress, and it supersedes any in-flight run. Otherwise
// (pull-to-refresh, foreground timer, app resume) it's a quiet
// check that never interrupts a run already in progress; those may
// additionally respect the Wi-Fi-only setting (`wifiOnly` present).
val force = call.argument<Boolean>("force") ?: true
val data = syncWorkData(call, force)
if (data == null) {
result.error("bad_args", "Missing required sync arguments", null)
return
}
val request = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data).build()
val wifiOnly = call.argument<Boolean>("wifiOnly")
val builder = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data)
if (wifiOnly != null) {
builder.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED,
)
.build(),
)
}
WorkManager.getInstance(this).enqueueUniqueWork(
SyncWorker.UNIQUE_ONE_OFF_NAME,
ExistingWorkPolicy.REPLACE,
request,
// Per account so switching accounts doesn't let one account's
// run replace/keep-out another's.
SyncWorker.oneOffNameFor(call.argument<String>("accountId")!!),
if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
builder.build(),
)
result.success(null)
}
@@ -0,0 +1,39 @@
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,
// Old channel ids this one replaces. A channel's importance (and so
// whether it makes sound) can't be changed once it's been created -
// Android ignores later changes - so changing it means moving to a
// new id and removing the old channel.
legacyIds: List<String> = emptyList(),
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
legacyIds.forEach { manager.deleteNotificationChannel(it) }
val channel = NotificationChannel(id, name, importance).apply {
this.description = description
if (importance <= NotificationManager.IMPORTANCE_LOW) {
setSound(null, null)
enableVibration(false)
}
}
manager.createNotificationChannel(channel)
}
}
@@ -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 {
@@ -51,20 +47,30 @@ class ShareUploadService : Service() {
const val EXTRA_AUTH_HEADER = "authHeader"
const val EXTRA_REMOTE_FOLDER = "remoteFolder"
private const val CHANNEL_ID = "share_upload"
private const val CHANNEL_ID = "share_upload_v2"
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<ShareFile>,
val serverUrl: String,
val username: String,
val authHeader: String,
val remoteFolder: String,
)
@Volatile
private var cancelled = false
private val queue = TransferQueue<UploadBatch> { 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,15 @@ class ShareUploadService : Service() {
return START_NOT_STICKY
}
createNotificationChannel()
NooNotificationChannels.ensure(
this,
CHANNEL_ID,
"File uploads",
// Default (not low) importance so a finished upload actually
// alerts; the progress updates themselves stay silent below.
NotificationManager.IMPORTANCE_DEFAULT,
"Progress for files shared to Noo",
)
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
@@ -92,16 +106,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 +122,22 @@ class ShareUploadService : Service() {
}
}
private fun runUploads(
files: List<ShareFile>,
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 +150,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 +178,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 +205,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 +221,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 {
@@ -288,6 +241,7 @@ class ShareUploadService : Service() {
.setContentTitle("Uploading to Noo")
.setContentText(text)
.setOnlyAlertOnce(true)
.setSilent(true)
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
when {
@@ -308,7 +262,7 @@ class ShareUploadService : Service() {
}
override fun onDestroy() {
cancelled.set(true)
cancelled = true
super.onDestroy()
}
}
@@ -7,10 +7,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlinx.coroutines.sync.Mutex
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
@@ -49,6 +49,27 @@ object SyncEngine {
val localMTime: Long,
)
/**
* What we last saw for a configured sync root when a walk of it fully
* completed cleanly: its own etag (Nextcloud propagates a change to any
* descendant up through every ancestor folder's etag, so an unchanged
* root etag means nothing beneath it changed on the server) and when
* that full walk happened. Lets a background pass answer "did anything
* change?" with one tiny Depth-0 PROPFIND per root instead of
* re-walking the whole tree every time.
*/
data class RootMarker(val etag: String, val fullWalkAt: Long)
/**
* The server couldn't be reached or answered with an error (anything
* but a clean 404). Distinct from "the path is genuinely gone/empty":
* treating a network blip as an empty listing made every previously
* synced file look deleted server-side, and the diff then deleted the
* local copies. Callers skip the affected path for this run instead.
*/
class RemoteUnavailableException(message: String, cause: Throwable? = null) :
Exception(message, cause)
sealed class SyncAction {
data class Download(val entry: RemoteEntry) : SyncAction()
data class Upload(val relPath: String, val fileId: String) : SyncAction()
@@ -56,6 +77,18 @@ object SyncEngine {
data class Conflict(val entry: RemoteEntry, val relPath: String) : SyncAction()
}
/**
* Held for the whole of any run that reads-then-rewrites a sync state
* map ([SyncWorker], [ConflictResolveWorker]). The periodic job and the
* one-off "sync now" jobs have different WorkManager names, so
* WorkManager itself happily runs them at once - and both load the
* state, mutate it, then save the *whole* map back, so whichever
* finished last silently discarded the other's updates. One process-wide
* lock serializes them (also keeps [SyncStatusBus], which only tracks
* one account at a time, coherent when several accounts are due).
*/
val syncLock = Mutex()
private const val TAG = "NooSync"
private const val STATE_PREFS = "noo_sync_state"
private val rfc1123 =
@@ -74,6 +107,108 @@ object SyncEngine {
}
private fun stateKey(accountId: String) = "state_$accountId"
private fun rootsKey(accountId: String) = "roots_$accountId"
private fun rootsVersionKey(accountId: String) = "roots_version_$accountId"
private fun missingKey(accountId: String) = "missing_$accountId"
// Bumped whenever a walk starts doing something new (e.g. mirroring
// empty folders) so markers recorded by an older version - which would
// make the next run skip the walk - are ignored once.
private const val ROOTS_VERSION = 2
fun loadRootMarkers(context: Context, accountId: String): MutableMap<String, RootMarker> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
if (prefs.getInt(rootsVersionKey(accountId), 0) != ROOTS_VERSION) return mutableMapOf()
val json = prefs.getString(rootsKey(accountId), null) ?: return mutableMapOf()
val obj = JSONObject(json)
val result = mutableMapOf<String, RootMarker>()
for (path in obj.keys()) {
val entry = obj.getJSONObject(path)
result[path] = RootMarker(entry.getString("etag"), entry.getLong("fullWalkAt"))
}
return result
}
fun saveRootMarkers(context: Context, accountId: String, markers: Map<String, RootMarker>) {
val obj = JSONObject()
for ((path, m) in markers) {
obj.put(path, JSONObject().apply {
put("etag", m.etag)
put("fullWalkAt", m.fullWalkAt)
})
}
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
prefs.edit()
.putString(rootsKey(accountId), obj.toString())
.putInt(rootsVersionKey(accountId), ROOTS_VERSION)
.apply()
}
/**
* Configured sync roots the server reported as gone (a clean 404) - e.g.
* a synced file or folder that was deleted on the web. The app removes
* these from its synced list (there's nothing left to sync) and then
* clears them via [removeLocalSync].
*/
fun loadMissingRoots(context: Context, accountId: String): Set<String> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
return prefs.getStringSet(missingKey(accountId), emptySet())?.toSet() ?: emptySet()
}
fun setRootMissing(context: Context, accountId: String, path: String, missing: Boolean) {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
val current = loadMissingRoots(context, accountId).toMutableSet()
val changed = if (missing) current.add(path) else current.remove(path)
if (changed) prefs.edit().putStringSet(missingKey(accountId), current).apply()
}
/** Creates a local directory for every remote folder, so empty folders exist on device too. */
fun mirrorFolders(entries: List<RemoteEntry>, syncRoot: File, rootRel: String) {
if (rootRel.isNotEmpty()) File(syncRoot, rootRel).mkdirs()
for (e in entries) {
if (e.isFolder) File(syncRoot, e.path.trim('/')).mkdirs()
}
}
/**
* Removes local directories under [rootRel] that no longer exist on the
* server ([remoteFolders], relative paths) - the counterpart to
* [mirrorFolders]. Only *empty* directories are removed: by the time this
* runs the files that were inside a deleted server folder have already
* been deleted by the diff, while a directory that still holds something
* (a file this device created that hasn't synced) is left alone. Never
* removes the sync root itself.
*/
fun pruneRemovedFolders(syncRoot: File, rootRel: String, remoteFolders: Set<String>) {
val top = if (rootRel.isEmpty()) syncRoot else File(syncRoot, rootRel)
if (!top.isDirectory) return
fun prune(dir: File, rel: String) {
dir.listFiles()?.filter { it.isDirectory }?.forEach { child ->
prune(child, if (rel.isEmpty()) child.name else "$rel/${child.name}")
}
if (rel.isNotEmpty() && rel !in remoteFolders && dir.list().isNullOrEmpty()) {
dir.delete()
}
}
prune(top, rootRel)
}
/**
* True if every file in [fileIds] is still on disk exactly as the sync
* state last recorded it (same size and mtime) - i.e. there's nothing
* local to upload and nothing missing to re-download. Pure local stat
* calls, no network.
*/
fun localMatchesState(
state: Map<String, FileState>,
fileIds: Collection<String>,
syncRoot: File,
): Boolean = fileIds.all { id ->
val s = state[id] ?: return@all false
val f = File(syncRoot, s.relPath)
f.exists() && f.length() == s.size && f.lastModified() == s.localMTime
}
fun loadState(context: Context, accountId: String): MutableMap<String, FileState> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
@@ -162,9 +297,10 @@ object SyncEngine {
return try {
httpClient.newCall(request).execute().use { response ->
Log.d(TAG, "PROPFIND $url -> ${response.code}")
if (response.code == 404) return emptyList() // genuinely gone
if (!response.isSuccessful) {
Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}")
return emptyList()
throw RemoteUnavailableException("PROPFIND $url -> ${response.code}")
}
val doc = DocumentBuilderFactory.newInstance()
@@ -174,9 +310,11 @@ object SyncEngine {
parsePropfindResponse(doc, username, cleanPath, skipSelf)
}
} catch (e: RemoteUnavailableException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "PROPFIND $url threw", e)
emptyList()
throw RemoteUnavailableException("PROPFIND $url threw", e)
}
}
@@ -258,33 +396,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 +416,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")
}
/**
@@ -353,11 +462,16 @@ object SyncEngine {
(localFile.lastModified() != prior.localMTime || localFile.length() != prior.size)
when {
!localExists && !serverChanged -> {
// User deleted the local mirror copy themselves and the
// server hasn't changed - respect that deletion rather
// than silently re-creating it.
actions.add(SyncAction.Delete(relPath, entry.fileId))
!localExists -> {
// State says this file was mirrored but the local copy
// is gone (stale state left over from an earlier sync of
// this path, or the file was removed outside the app).
// It's inside a path the user asked to sync, so pull it
// back down. This used to be treated as "the user
// deleted it, respect that" and emitted a Delete - which
// dropped the state, skipped the download, and reported
// phantom "N removed" for files that were never on disk.
actions.add(SyncAction.Download(entry))
}
serverChanged && localChanged -> actions.add(SyncAction.Conflict(entry, relPath))
serverChanged -> actions.add(SyncAction.Download(entry))
@@ -386,6 +500,53 @@ object SyncEngine {
return actions
}
/**
* Deletes [path]'s local mirror (a single file, or a whole folder's
* worth of files) and removes its entries from the persisted sync-state
* map, called when the user turns sync off for that path - without
* clearing the state too, a later re-add would see the (now missing)
* local file as "deleted, server unchanged" and skip re-downloading it
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
* of pulling it back down.
*/
fun removeLocalSync(context: Context, accountId: String, path: String) {
val root = syncRoot(context, accountId)
val state = loadState(context, accountId).toMutableMap()
var cleanPath = path.trim()
if (cleanPath.startsWith("/")) cleanPath = cleanPath.substring(1)
cleanPath = cleanPath.trimEnd('/')
val prefix = if (cleanPath.isEmpty()) "" else "$cleanPath/"
val toRemove = state.filterValues { s ->
s.relPath == cleanPath || (prefix.isNotEmpty() && s.relPath.startsWith(prefix))
}.keys
for (fileId in toRemove) state.remove(fileId)
saveState(context, accountId, state)
setRootMissing(context, accountId, "/" + cleanPath, false)
setRootMissing(context, accountId, cleanPath, false)
// A root marker for this path (or an ancestor/descendant that also
// covers some of what was just deleted) would make the next
// background pass think nothing changed and skip re-downloading.
val removedPath = "/" + cleanPath
val markers = loadRootMarkers(context, accountId)
val staleKeys = markers.keys.filter { key ->
val k = key.trimEnd('/')
k.isEmpty() || removedPath == "/" || k == removedPath ||
k.startsWith("$removedPath/") || removedPath.startsWith("$k/")
}
if (staleKeys.isNotEmpty()) {
staleKeys.forEach { markers.remove(it) }
saveRootMarkers(context, accountId, markers)
}
val target = if (cleanPath.isEmpty()) root else File(root, cleanPath)
if (target.exists()) target.deleteRecursively()
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
}
fun folderIsSyncedUnder(itemPath: String, syncedFolders: List<String>): String? {
val normalizedItem = itemPath.trimEnd('/')
for (folder in syncedFolders) {
@@ -1,17 +1,16 @@
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
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.io.File
@@ -34,27 +33,94 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
const val KEY_AUTH_HEADER = "authHeader"
const val KEY_FOLDERS = "folders" // JSON array of remote file/folder paths
const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic"
const val UNIQUE_ONE_OFF_NAME = "noo_sync_now"
// True for a user-initiated "Sync now": always does a full walk of
// every root (never trusts the root-etag shortcut) and shows the
// "Checking for changes..." notification immediately. False for
// periodic/automatic runs, which stay silent unless something
// actually transfers.
const val KEY_FORCE = "force"
private const val CHANNEL_ID = "device_sync"
private const val SUMMARY_NOTIFICATION_ID = 4401
private const val CONFLICT_NOTIFICATION_ID_BASE = 4500
// Whether automatic (non-force) runs may post progress/summary
// notifications - the "Background sync notifications" setting.
// Conflicts are always notified (they need a decision), and a
// user-initiated run (force) always shows progress.
const val KEY_NOTIFY = "notify"
// Even when a root's etag hasn't changed, walk it in full at least
// this often: etag propagation isn't reliable everywhere (external
// storage mounts in particular), so this bounds how stale a mirror
// can get if the cheap check is ever wrong.
private const val FULL_WALK_MAX_AGE_MS = 6L * 60 * 60 * 1000
// Pre-multi-account name of the single shared periodic job - only
// kept so it can be cancelled when migrating to per-account jobs.
const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic"
// Each account with sync enabled gets its own periodic job (with
// that account's own credentials/paths/interval baked in), so
// background sync works for every enabled account, not just
// whichever one was active last.
fun periodicNameFor(accountId: String) = "${UNIQUE_PERIODIC_NAME}_$accountId"
fun oneOffNameFor(accountId: String) = "noo_sync_now_$accountId"
// Low importance = no sound/vibration. Importance can't be changed
// on an existing channel, hence the new id (the old "device_sync"
// channel, which made noise, is deleted when this one is created).
private const val CHANNEL_ID = "device_sync_v2"
private const val LEGACY_CHANNEL_ID = "device_sync"
// Notification ids are per account so two accounts syncing never
// overwrite each other's progress/summary/conflict notifications.
// Progress and the final summary share one id per account - the
// summary's notify() call naturally replaces the ongoing progress
// notification in place (no flicker of two separate notifications),
// and if there was nothing to actually sync, doWork explicitly
// cancels it since no summary gets posted to replace it.
private const val SUMMARY_NOTIFICATION_ID_BASE = 100_000
private const val CONFLICT_NOTIFICATION_ID_BASE = 200_000
fun summaryNotificationId(accountId: String) =
SUMMARY_NOTIFICATION_ID_BASE + (accountId.hashCode() and 0xFFFF)
fun conflictNotificationId(accountId: String, relPath: String) =
CONFLICT_NOTIFICATION_ID_BASE + ((accountId + "|" + relPath).hashCode() and 0xFFFF)
private const val TAG = "NooSync"
}
// totalTransfers is only known once at least one path's been diffed -
// grows across the run as each configured path is diffed in turn.
private var totalTransfers = 0
private var completedTransfers = 0
private var progressShown = false
// Set at the top of performSync - the notify* helpers below need the
// account (for its notification ids / title) and whether this run may
// show progress at all.
private var notifyAccountId = ""
private var notifyUsername = ""
private var showProgressNotifications = true
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
// One sync at a time, app-wide - see SyncEngine.syncLock. A run that
// has to wait just starts once the current one finishes.
SyncEngine.syncLock.withLock { performSync() }
}
private suspend fun performSync(): Result = withContext(Dispatchers.IO) {
val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure()
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure()
val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure()
val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure()
val foldersJson = inputData.getString(KEY_FOLDERS) ?: "[]"
val force = inputData.getBoolean(KEY_FORCE, false)
notifyAccountId = accountId
notifyUsername = username
showProgressNotifications = force || inputData.getBoolean(KEY_NOTIFY, true)
val folders = (0 until JSONArray(foldersJson).length()).map { JSONArray(foldersJson).getString(it) }
Log.d(TAG, "doWork: account=$accountId server=$serverUrl user=$username folders=$folders")
if (folders.isEmpty()) return@withContext Result.success()
val syncRoot = SyncEngine.syncRoot(applicationContext, accountId)
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
val markers = SyncEngine.loadRootMarkers(applicationContext, accountId)
var downloaded = 0
var uploaded = 0
@@ -62,81 +128,180 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
val conflicts = mutableListOf<Pair<SyncEngine.RemoteEntry, String>>()
SyncStatusBus.setSyncing(accountId, true)
// For a user-initiated run, shown immediately rather than lazily on
// the first transfer - the PROPFIND walk/diff below (per configured
// path, potentially recursive) can itself take real time, and the
// user just asked for a sync so they expect to see it happening.
// Automatic runs stay silent until something actually transfers.
if (force) notifyProgress("Checking for changes…")
try {
for (path in folders) {
// A configured path can be a file or a folder now - check
// which before deciding whether to walk it recursively or
// just diff the single item.
val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path)
val entries = when {
self == null -> emptyList()
self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path)
else -> listOf(self)
}
val pathPrefix = path.trimEnd('/') + "/"
val priorFileIdsForPath = state.filterValues {
it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/'))
}.keys
try {
// A configured path can be a file or a folder now - check
// which before deciding whether to walk it recursively or
// just diff the single item.
val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path)
val pathPrefix = path.trimEnd('/') + "/"
val priorFileIdsForPath = state.filterValues {
it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/'))
}.keys
val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot)
for (action in actions) {
when (action) {
is SyncEngine.SyncAction.Download -> {
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true)
val relPath = action.entry.path.removePrefix("/")
val dest = File(syncRoot, relPath)
if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) {
state[action.entry.fileId] = SyncEngine.FileState(
relPath = relPath,
etag = action.entry.etag,
lastModified = action.entry.lastModified,
size = action.entry.size,
localMTime = dest.lastModified(),
)
downloaded++
}
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false)
}
is SyncEngine.SyncAction.Upload -> {
SyncStatusBus.markFileSyncing(accountId, action.fileId, true)
val localFile = File(syncRoot, action.relPath)
val remotePath = "/${action.relPath}"
if (localFile.exists() &&
SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile)
) {
val prior = state[action.fileId]
state[action.fileId] = SyncEngine.FileState(
relPath = action.relPath,
etag = prior?.etag ?: "",
lastModified = localFile.lastModified(),
size = localFile.length(),
localMTime = localFile.lastModified(),
)
uploaded++
}
SyncStatusBus.markFileSyncing(accountId, action.fileId, false)
}
is SyncEngine.SyncAction.Delete -> {
File(syncRoot, action.relPath).delete()
state.remove(action.fileId)
deleted++
}
is SyncEngine.SyncAction.Conflict -> conflicts.add(action.entry to action.relPath)
// Cheap "did anything change?" check: an unchanged root etag
// means nothing beneath it changed on the server, and if
// every local file is also still exactly as recorded there's
// nothing to upload or re-download either - so skip the walk
// (one Depth-0 PROPFIND instead of one per subfolder).
val marker = markers[path]
if (!force && self != null && self.isFolder && marker != null &&
marker.etag == self.etag &&
System.currentTimeMillis() - marker.fullWalkAt < FULL_WALK_MAX_AGE_MS &&
priorFileIdsForPath.isNotEmpty() &&
SyncEngine.localMatchesState(state, priorFileIdsForPath, syncRoot)
) {
Log.d(TAG, "skip $path: server etag unchanged, local intact")
continue
}
// The server says this synced path no longer exists (a
// clean 404, not a network error): flag it so the app drops
// it from the synced list instead of leaving a dead entry.
SyncEngine.setRootMissing(applicationContext, accountId, path, self == null)
val entries = when {
self == null -> emptyList()
self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path)
else -> listOf(self)
}
var pathClean = true
val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot)
totalTransfers += actions.count {
it is SyncEngine.SyncAction.Download || it is SyncEngine.SyncAction.Upload
}
for (action in actions) {
when (action) {
is SyncEngine.SyncAction.Download -> {
val relPath = action.entry.path.removePrefix("/")
notifyProgress(relPath.substringAfterLast('/'))
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true)
val dest = File(syncRoot, relPath)
if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) {
state[action.entry.fileId] = SyncEngine.FileState(
relPath = relPath,
etag = action.entry.etag,
lastModified = action.entry.lastModified,
size = action.entry.size,
localMTime = dest.lastModified(),
)
downloaded++
} else {
pathClean = false
}
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false)
completedTransfers++
}
is SyncEngine.SyncAction.Upload -> {
notifyProgress(action.relPath.substringAfterLast('/'))
SyncStatusBus.markFileSyncing(accountId, action.fileId, true)
val localFile = File(syncRoot, action.relPath)
val remotePath = "/${action.relPath}"
if (localFile.exists() &&
SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile)
) {
val prior = state[action.fileId]
state[action.fileId] = SyncEngine.FileState(
relPath = action.relPath,
etag = prior?.etag ?: "",
lastModified = localFile.lastModified(),
size = localFile.length(),
localMTime = localFile.lastModified(),
)
uploaded++
} else {
pathClean = false
}
SyncStatusBus.markFileSyncing(accountId, action.fileId, false)
completedTransfers++
}
is SyncEngine.SyncAction.Delete -> {
// Only counts toward the "removed" summary if a
// local copy actually existed and was deleted -
// dropping stale state for a file that was never
// on disk isn't a removal the user should hear
// about.
val localFile = File(syncRoot, action.relPath)
if (localFile.exists() && localFile.delete()) deleted++
state.remove(action.fileId)
}
is SyncEngine.SyncAction.Conflict -> {
conflicts.add(action.entry to action.relPath)
pathClean = false
}
}
}
// Mirror the folder structure itself: empty folders
// created on the server appear on device, and folders
// deleted on the server (whose files the diff above has
// already removed) don't linger as empty directories.
if (self == null || self.isFolder) {
val rootRel = path.trim('/')
if (self != null) SyncEngine.mirrorFolders(entries, syncRoot, rootRel)
val remoteFolders = entries.filter { it.isFolder }
.map { it.path.trim('/') }
.toMutableSet()
if (self != null) remoteFolders.add(rootRel)
SyncEngine.pruneRemovedFolders(syncRoot, rootRel, remoteFolders)
}
// Only remember this root's etag once the whole path synced
// without a hitch - anything left over (a failed transfer, an
// unresolved conflict) has to be retried by a real walk next
// time, not skipped because the etag "hasn't changed".
if (self != null && self.isFolder && pathClean) {
markers[path] = SyncEngine.RootMarker(self.etag, System.currentTimeMillis())
} else {
markers.remove(path)
}
} catch (e: SyncEngine.RemoteUnavailableException) {
// Couldn't reach the server for this path - leave its state
// and marker untouched and try again next run. Crucially,
// never diff against an empty listing here: that would look
// like the whole folder was deleted server-side.
Log.w(TAG, "skipping $path this run: ${e.message}")
}
}
} finally {
markers.keys.retainAll(folders.toSet())
SyncEngine.saveRootMarkers(applicationContext, accountId, markers)
// State must be saved *before* announcing that syncing has
// stopped - `syncStatusMap` (MainActivity.kt) recomputes
// `syncedFileIds` fresh from `SyncEngine.loadState` every time
// this bus publishes, so publishing first would hand Dart a
// snapshot that's still missing every file this run just
// downloaded, and nothing would ever correct it afterward
// (no further bus event fires post-save) - newly-synced files
// would never show their "synced" badge until the next app
// restart forced a fresh `getStatus()` read.
SyncEngine.saveState(applicationContext, accountId, state)
SyncStatusBus.setSyncing(accountId, false)
}
SyncEngine.saveState(applicationContext, accountId, state)
Log.d(
TAG,
"doWork done: downloaded=$downloaded uploaded=$uploaded deleted=$deleted conflicts=${conflicts.size}",
)
if (downloaded > 0 || uploaded > 0 || deleted > 0) {
if ((downloaded > 0 || uploaded > 0 || deleted > 0) && showProgressNotifications) {
// Replaces the ongoing progress notification in place (same
// ID) with a final, dismissible summary.
notifySummary(downloaded, uploaded, deleted)
} else if (progressShown) {
// Nothing actually transferred (e.g. every diffed action was a
// Delete/Conflict, or the progress notification was shown for a
// run that ended up empty) - nothing to replace it with, so
// just clear it rather than leaving a stale "Syncing…" behind.
manager().cancel(summaryNotificationId(accountId))
}
if (conflicts.isNotEmpty()) {
SyncStatusBus.addConflicts(
@@ -157,6 +322,39 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
Result.success()
}
/// Called once immediately at the start of every run that actually has
/// folders configured (covers the PROPFIND walk/diff phase, which can
/// itself take real time), then again per file as transfers happen.
/// `totalTransfers` grows as each configured path is diffed (paths are
/// diffed one at a time), so early on this shows an indeterminate bar;
/// it becomes determinate once the true total for this run is known. A
/// run that turns out to have nothing to transfer cancels this
/// notification at the end instead of leaving it stuck (see the
/// `progressShown` check in `doWork`).
private fun notifyProgress(fileName: String) {
if (!showProgressNotifications) return
progressShown = true
createChannel()
val text = if (totalTransfers > 1) {
"$fileName (${completedTransfers + 1}/$totalTransfers)"
} else {
fileName
}
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Syncing to device · $notifyUsername")
.setContentText(text)
.setOnlyAlertOnce(true)
.setSilent(true)
.setOngoing(true)
if (totalTransfers > 0) {
builder.setProgress(totalTransfers, completedTransfers, false)
} else {
builder.setProgress(0, 0, true)
}
manager().notify(summaryNotificationId(notifyAccountId), builder.build())
}
private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) {
createChannel()
val parts = mutableListOf<String>()
@@ -167,11 +365,12 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Noo sync")
.setContentTitle("Noo sync · $notifyUsername")
.setContentText(text)
.setAutoCancel(true)
.setSilent(true)
.build()
manager().notify(SUMMARY_NOTIFICATION_ID, notification)
manager().notify(summaryNotificationId(notifyAccountId), notification)
}
private fun notifyConflicts(
@@ -185,7 +384,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
for ((index, conflict) in conflicts.withIndex()) {
val (entry, relPath) = conflict
val fileName = relPath.substringAfterLast('/')
val notificationId = CONFLICT_NOTIFICATION_ID_BASE + (relPath.hashCode() and 0xFFFF)
val notificationId = conflictNotificationId(accountId, relPath)
val useLocalIntent = conflictActionIntent(
accountId,
@@ -213,8 +412,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Sync conflict: $fileName")
.setContentText("Changed both on this device and on the server.")
.setContentText("$username - changed both on this device and on the server.")
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Keep local", useLocalIntent)
.addAction(0, "Use server", useServerIntent)
.build()
@@ -247,7 +447,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
}
// Request code must be unique per (file, resolution) pair, else the
// two actions' PendingIntents collide and only one survives.
val requestCode = (relPath + resolution).hashCode()
val requestCode = (accountId + relPath + resolution).hashCode()
return PendingIntent.getBroadcast(
applicationContext,
requestCode,
@@ -260,13 +460,13 @@ 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_LOW,
"Updates and conflicts for folders synced to this device",
legacyIds = listOf(LEGACY_CHANNEL_ID),
)
}
}
@@ -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<T>(private val process: (T) -> Unit) {
private val queue = LinkedBlockingQueue<T>()
@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()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

@@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon>
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+110 -28
View File
@@ -6,7 +6,19 @@ 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/connectivity_controller.dart';
import 'providers/favorites_controller.dart';
import 'providers/files_controller.dart';
import 'providers/item_operations.dart';
import 'providers/offline_controller.dart';
import 'providers/photos_controller.dart';
import 'providers/pick_controller.dart';
import 'providers/recent_controller.dart';
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 +32,69 @@ 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: (_) => ConnectivityController()),
ChangeNotifierProvider(
create: (context) => SessionController(context.read()),
),
ChangeNotifierProvider(create: (_) => SettingsController()),
// Before SyncStatusController, which follows its Files Cache rule
// for how often synced files refresh.
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
// Eager (`lazy: false`) - this controller only loads its persisted
// state when SessionController fires the account-ready event, and
// that fires once at startup, before any widget has read this
// provider. Lazily created, it would register its listener too
// late and stay empty ("Sync off") until an account switch.
lazy: false,
create: (context) =>
SyncStatusController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) =>
PhotosController(context.read(), context.read()),
),
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()),
),
ChangeNotifierProvider(
// Eager for the same reason as SyncStatusController above.
lazy: false,
create: (context) =>
OfflineController(context.read(), context.read()),
),
Provider(
create: (context) => ItemOperations(
session: context.read(),
files: context.read(),
photos: context.read(),
favorites: context.read(),
),
),
],
child: const NextcloudApp(),
),
);
@@ -32,7 +105,8 @@ class NextcloudApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
final session = context.watch<SessionController>();
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) {
@@ -40,22 +114,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 +202,7 @@ class _MainShellViewState extends State<MainShellView> {
// 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<ServerProvider>().defaultTab;
_currentTab = context.read<SettingsController>().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 +217,11 @@ class _MainShellViewState extends State<MainShellView> {
// another app may have launched Noo as its GET_CONTENT picker.
PickIntentService.getPickRequest().then((request) {
if (request != null && mounted) {
context.read<ServerProvider>().setPickRequest(request);
context.read<PickController>().setPickRequest(request);
}
});
_pickSub = PickIntentService.onNewPickRequest.listen((request) {
if (mounted) context.read<ServerProvider>().setPickRequest(request);
if (mounted) context.read<PickController>().setPickRequest(request);
});
WidgetsBinding.instance.addPostFrameCallback(
@@ -220,29 +294,37 @@ class _MainShellViewState extends State<MainShellView> {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>();
final connectivity = context.watch<ConnectivityController>();
// A one-shot request (e.g. tapping a search result) to switch tabs -
// consumed here so it only fires once, then cleared after this frame
// (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'
// views even know how to handle picking-mode taps. This overrides the
// user's own hidden/reordered tab settings rather than respecting
// them, since picking is a separate mode from normal browsing.
// them, since picking is a separate mode from normal browsing. With
// no network at all, every other tab would just show its own loading
// spinner forever/an error state, so the bottom nav collapses to just
// Offline - the one tab that works without a connection (see
// `FilesView(offline: true)`/`ConnectivityController`).
final visible = pickRequest != null
? [AppTab.files, AppTab.photos]
: provider.visibleTabs;
: connectivity.isOffline
? [AppTab.offline]
: settings.visibleTabs;
final selectedTab = visible.contains(_currentTab)
? _currentTab
: (visible.isNotEmpty ? visible.first : AppTab.files);
@@ -260,7 +342,7 @@ class _MainShellViewState extends State<MainShellView> {
// 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 +353,7 @@ class _MainShellViewState extends State<MainShellView> {
.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 +361,12 @@ class _MainShellViewState extends State<MainShellView> {
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 +397,7 @@ class _MainShellViewState extends State<MainShellView> {
/// 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();
+15 -2
View File
@@ -3,8 +3,17 @@ 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].
enum AppTab { files, photos, favorites, activity, trash, shares, recent }
/// configurable and persisted in `SettingsController`.
enum AppTab {
files,
photos,
favorites,
activity,
trash,
shares,
recent,
offline,
}
/// At most this many tabs may be visible in the bottom nav bar at once —
/// the rest are reachable through the "more" dropdown instead.
@@ -27,6 +36,8 @@ extension AppTabInfo on AppTab {
return 'Shares';
case AppTab.recent:
return 'Recent';
case AppTab.offline:
return 'Offline';
}
}
@@ -46,6 +57,8 @@ extension AppTabInfo on AppTab {
return Icons.groups_rounded;
case AppTab.recent:
return Symbols.search_activity_rounded;
case AppTab.offline:
return Icons.offline_pin_rounded;
}
}
}
+2 -2
View File
@@ -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).
+1 -1
View File
@@ -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 }
@@ -0,0 +1,53 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
/// Whether the device currently has *any* network route (Wi-Fi, mobile
/// data, ethernet - not a guarantee the Nextcloud server itself is
/// reachable, just OS-level connectivity). The single source of truth
/// other controllers/views consult before attempting a network request or
/// deciding whether to fall back to offline-only behavior - see
/// `SessionController` (skips its login-restore HTTP call entirely while
/// offline) and `MainShellView` (restricts the bottom nav to just the
/// Offline tab while offline).
class ConnectivityController extends ChangeNotifier {
bool _isOffline = false;
StreamSubscription<List<ConnectivityResult>>? _sub;
Timer? _recheckTimer;
bool get isOffline => _isOffline;
ConnectivityController() {
Connectivity().checkConnectivity().then(_apply);
_sub = Connectivity().onConnectivityChanged.listen(_apply);
// The very first check can spuriously report "no network" while the
// OS's connectivity stack is still attaching callbacks to this
// just-started process - a real Android cold-start quirk, not a
// genuine transition. That matters here specifically because
// onConnectivityChanged only fires on actual transitions, so a false
// initial "offline" read would otherwise stick for the rest of the
// session (SessionController would keep treating every login as
// provisional, and every network-fetching controller would never get
// its real activation signal - see addAccountReadyListener's doc
// comment) even though the device was online the whole time. Re-
// verify once, shortly after, to correct it.
_recheckTimer = Timer(const Duration(seconds: 2), () {
Connectivity().checkConnectivity().then(_apply);
});
}
void _apply(List<ConnectivityResult> results) {
final offline =
results.isEmpty || results.every((r) => r == ConnectivityResult.none);
if (offline == _isOffline) return;
_isOffline = offline;
notifyListeners();
}
@override
void dispose() {
_sub?.cancel();
_recheckTimer?.cancel();
super.dispose();
}
}
+110
View File
@@ -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<NextcloudItem> _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<NextcloudItem> 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<void> 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<void> 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();
}
}
+697
View File
@@ -0,0 +1,697 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/nextcloud_item.dart';
import 'folder_browser.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. Synced files are checked each time the app
/// opens (and on pull-to-refresh), but not on a background schedule.
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.
/// Synced files likewise only update on pull-to-refresh / "Sync now".
manual,
}
/// New accounts (and anyone who's never touched the setting) refresh
/// periodically - the Files Cache rule also drives how often synced
/// folders/files are brought in line with the server, see
/// `SyncStatusController`.
const defaultCachePolicy = CachePolicy.interval;
const defaultCacheIntervalMinutes = 15;
/// One cached folder listing and when it was fetched.
class _CachedDirectory {
final List<NextcloudItem> 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
implements FolderBrowser {
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<String> _pathStack = ['/'];
bool _isGridView = false;
StorageScope _storageScope = StorageScope.cloud;
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
bool _showHiddenFiles = false;
final Map<String, FileSortField> _folderSortField = {};
final Map<String, bool> _folderSortAscending = {};
CachePolicy _cachePolicy = defaultCachePolicy;
int _cacheIntervalMinutes = defaultCacheIntervalMinutes;
final Map<String, _CachedDirectory> _directoryCache = {};
Timer? _cacheRefreshTimer;
// Auto-retry of a failed (not in-place) load that looks like a network
// hiccup - see [_scheduleNetworkRetry].
Timer? _retryTimer;
int _retryAttempt = 0;
List<NextcloudItem> _items = [];
NextcloudUserQuota? _quota;
List<NextcloudActivity> _activities = [];
bool _isLoading = false;
String? _errorMessage;
FilesController(this.session) {
WidgetsBinding.instance.addObserver(this);
session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated);
session.addAccountReadyListener(_restoreDisplayPrefs);
}
// Getters
@override
String get currentFolderPath => _currentFolderPath;
@override
List<String> get pathStack => _pathStack;
bool get isGridView => _isGridView;
StorageScope get storageScope => _storageScope;
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
bool get showHiddenFiles => _showHiddenFiles;
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
/// Per-folder sort prefs keyed by remote path - also what the Offline tab
/// reads for the same path in the local mirror, so a folder sorts the
/// same in both places.
FileSortField sortFieldFor(String path) =>
_folderSortField[path] ?? FileSortField.name;
bool sortAscendingFor(String path) => _folderSortAscending[path] ?? true;
CachePolicy get cachePolicy => _cachePolicy;
int get cacheIntervalMinutes => _cacheIntervalMinutes;
@override
bool get isLoading => _isLoading;
@override
String? get errorMessage => _errorMessage;
NextcloudUserQuota? get quota => _quota;
List<NextcloudActivity> get activities => _activities;
@override
List<NextcloudItem> 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<NextcloudItem> applyCommonFilters(
List<NextcloudItem> source, {
required bool showFavoritesOnly,
required bool showHidden,
bool applyStorageScope = true,
}) {
var filtered = source;
if (showFavoritesOnly) {
filtered = filtered.where((item) => item.isFavorite).toList();
}
if (!showHidden) {
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
}
if (applyStorageScope) {
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.
///
/// [folderPath] picks which folder's sort prefs apply (default: the
/// Files tab's current folder). [applyStorageScope] is off for the
/// Offline tab, whose local mirror has no cloud/external distinction.
List<NextcloudItem> applyFilesDisplayPrefs(
List<NextcloudItem> rawItems, {
String? folderPath,
bool applyStorageScope = true,
}) {
var filtered = applyCommonFilters(
rawItems,
// Files itself doesn't filter by favorite - that's the dedicated
// Favorites tab's job.
showFavoritesOnly: false,
showHidden: _showHiddenFiles,
applyStorageScope: applyStorageScope,
);
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 path = folderPath ?? _currentFolderPath;
final field = sortFieldFor(path);
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 sortAscendingFor(path)
? [...folders, ...files]
: [...folders.reversed, ...files.reversed];
}
void _onAccountCleared() {
_prefsRestore = null;
_retryTimer?.cancel();
_retryAttempt = 0;
_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 = defaultCachePolicy;
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
// Memoized per account (reset in _onAccountCleared) so the ready-event
// and activated-event paths below share one restore instead of racing
// two reads of the same prefs.
Future<void>? _prefsRestore;
/// Loads this account's display prefs (grid/list, hidden, type filter,
/// sort, cache policy) - hooked to `addAccountReadyListener` as well as
/// activation so the Offline tab, which reads these same prefs, respects
/// them even on a provisional/offline login where activation never fires.
Future<void> _restoreDisplayPrefs() => _prefsRestore ??= _doRestoreDisplayPrefs();
/// Completes once this account's cache policy/display prefs have been
/// loaded from storage - lets `SyncStatusController` schedule background
/// sync from the user's real policy instead of the defaults.
Future<void> get displayPrefsLoaded => _restoreDisplayPrefs();
Future<void> _doRestoreDisplayPrefs() async {
final id = session.activeAccountId;
if (id != null) {
// The whole prefs-restore block is wrapped, not just the JSON
// decode below - a single bad/mistyped stored value here (an
// unguarded `prefs.getBool`/`getString`/`getInt` throws if the
// key holds a different runtime type than requested) would
// otherwise propagate uncaught out of this method entirely,
// silently skipping `refreshData()` below and leaving the Files
// tab permanently empty on this activation with no error surfaced
// anywhere - exactly what happened before this was guarded.
try {
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<String, dynamic>;
for (final entry in decoded.entries) {
final value = entry.value as Map<String, dynamic>;
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: () => defaultCachePolicy,
);
_cacheIntervalMinutes =
prefs.getInt(k(prefCacheIntervalMinutes)) ??
defaultCacheIntervalMinutes;
notifyListeners();
} catch (e) {
debugPrint('[FilesController] Account-activation prefs restore failed: $e');
}
}
}
Future<void> _onAccountActivated() async {
await _restoreDisplayPrefs();
_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<void> 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;
_retryTimer?.cancel();
// Refreshing a folder that's already on screen (pull-to-refresh, the
// periodic cache refresh) swaps the listing in place once the server
// answers - blanking the list to a spinner first made every refresh
// look like the files vanished and reappeared. The spinner is only for
// when there's nothing to show yet (first load, just navigated into a
// new folder - see [_navigateTo], which clears the outgoing folder's
// items - or retrying after an error).
final inPlace = _items.isNotEmpty && _errorMessage == null;
if (!inPlace) {
_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(),
);
// Show the new listing now rather than after the quota/activity
// fetches below - they're unrelated to what the list displays.
_isLoading = false;
_retryAttempt = 0;
notifyListeners();
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');
// A failed in-place refresh keeps the (slightly stale) listing on
// screen instead of replacing it with an error page.
if (!inPlace) {
_errorMessage = e.toString().replaceAll('Exception: ', '');
_scheduleNetworkRetry(e);
}
} finally {
if (gen == session.sessionGeneration) {
_isLoading = false;
notifyListeners();
}
}
}
/// The OS reports "connected" a moment before the route actually works,
/// so the first request after regaining Wi-Fi/data can fail with "Network
/// is unreachable" - leaving the Files tab on an error page until the
/// user taps Retry. Quietly retry a few times (2s, 4s, 8s, 15s) when the
/// failure is network-level and the device is online; a real server error
/// (auth, 5xx) isn't retried.
void _scheduleNetworkRetry(Object error) {
final text = error.toString();
final isNetworkError =
text.contains('SocketException') ||
text.contains('ClientException') ||
text.contains('TimeoutException') ||
text.contains('Network is unreachable');
if (!isNetworkError ||
_retryAttempt >= 4 ||
session.connectivity.isOffline) {
return;
}
final delays = [2, 4, 8, 15];
final delay = Duration(seconds: delays[_retryAttempt]);
_retryAttempt++;
_retryTimer = Timer(delay, () => unawaited(refreshData()));
}
Future<List<NextcloudItem>> searchFiles(String query) async {
final service = session.service;
if (service == null) return [];
return service.searchFiles(query);
}
@override
Future<void> reload() => refreshData();
@override
Future<void> navigateToFolder(String path) async {
_pathStack.add(path);
await _navigateTo(path);
}
@override
Future<void> 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.
@override
Future<void> 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<void> navigateToAbsoluteFolder(String path) async {
final normalized = path.trim().replaceAll(RegExp(r'/+$'), '');
final segments = normalized.split('/').where((s) => s.isNotEmpty).toList();
final stack = <String>['/'];
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<void> _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;
}
// Don't leave the folder just left on screen under the new breadcrumb
// while this one loads - clearing it is what makes [refreshData] show
// its spinner (rather than refreshing in place) for a navigation.
_items = [];
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<List<NextcloudItem>> fetchFolderListing(String path) {
return session.service?.fetchDirectory(path) ?? Future.value([]);
}
void invalidateCache() => _directoryCache.clear();
/// Persists a per-account browsing pref under its `acct_<id>_`-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<void> 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 = <String, dynamic>{};
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) =>
setSortFieldFor(_currentFolderPath, field);
void toggleFilesSortOrder() => toggleSortOrderFor(_currentFolderPath);
void setSortFieldFor(String path, FileSortField field) {
if (sortFieldFor(path) == field) return;
_folderSortField[path] = field;
notifyListeners();
_persistFolderSort();
}
void toggleSortOrderFor(String path) {
_folderSortAscending[path] = !sortAscendingFor(path);
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<bool> 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();
}
}
+31
View File
@@ -0,0 +1,31 @@
import '../models/nextcloud_item.dart';
/// What `FilesView` needs from whatever is supplying the folder it's
/// showing: the breadcrumb trail, the (already display-pref-filtered and
/// sorted) items, loading/error state, and navigation. Implemented by
/// [FilesController] (the server's folders) and [OfflineController] (the
/// device-sync mirror of those same folders) so the Files tab and the
/// Offline tab are literally the same view over two data sources.
///
/// Deliberately not a `Listenable` - `context.watch` subscribes to the
/// concrete provider registered in `main()`, not to this interface, so the
/// view picks the concrete controller and then treats it as a
/// [FolderBrowser].
abstract interface class FolderBrowser {
List<String> get pathStack;
String get currentFolderPath;
/// Items in [currentFolderPath] with the shared Files display prefs
/// (hidden files, type filter, sort) already applied.
List<NextcloudItem> get items;
bool get isLoading;
String? get errorMessage;
Future<void> navigateToFolder(String path);
Future<void> navigateToPathIndex(int index);
Future<void> navigateUp();
/// Re-reads the current folder (pull-to-refresh / retry).
Future<void> reload();
}
+358
View File
@@ -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<bool> 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<bool> 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<MoveCopyResult> _moveOrCopyItems(
List<NextcloudItem> 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 = <MoveCopyConflict>[];
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<MoveCopyResult> moveItems(
List<NextcloudItem> items,
String destFolderPath,
) {
return _moveOrCopyItems(items, destFolderPath, copy: false);
}
/// Same as [moveItems] but duplicates rather than relocates.
Future<MoveCopyResult> copyItems(
List<NextcloudItem> 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<String> 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<MoveCopyResult> resolveConflicts(
List<MoveCopyConflict> conflicts,
String destFolderPath, {
required bool copy,
required Map<String, ConflictChoice> 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()
: <String>{};
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<void> 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<String?> 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<List<NextcloudShare>> 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<List<NextcloudShare>> fetchInheritedShares(NextcloudItem item) async {
final service = session.service;
if (service == null) return [];
return service.fetchInheritedShares(item.path);
}
Future<List<NextcloudSharee>> 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<NextcloudShare?> 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<bool> deleteShare(NextcloudShare share) async {
final service = session.service;
if (service == null) return false;
return service.deleteShare(share.id);
}
Future<List<NextcloudActivity>> fetchFileActivity(NextcloudItem item) async {
final service = session.service;
if (service == null) return [];
return service.fetchFileActivity(item.id);
}
Future<List<NextcloudFileVersion>> fetchFileVersions(
NextcloudItem item,
) async {
final service = session.service;
if (service == null) return [];
return service.fetchFileVersions(item.id);
}
Future<bool> 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<bool> 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;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
import 'dart:async';
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 '../services/sync_service.dart';
import 'files_controller.dart';
import 'folder_browser.dart';
import 'session_controller.dart';
/// The Offline tab's data source: `FilesView` (the same view as the Files
/// tab) over whatever device-sync has actually mirrored to local storage,
/// i.e. the Files tab's folders filtered down to what's available offline -
/// read straight off
/// disk, no server round-trip, since the whole point is browsing what's
/// available without a connection. Walks
/// `<externalFilesDir>/sync/<accountId>/...` directly (mirrors
/// `SyncEngine.kt#syncRoot`'s layout exactly, same technique
/// `SyncStatusController.localSyncedFilePath` already relies on) rather
/// than round-tripping through a MethodChannel. Listens for
/// [SessionController.addAccountReadyListener] rather than
/// `addAccountActivatedListener` - this controller's own work is entirely
/// local, so it's safe to run even on a provisional/offline login (see
/// that method's doc comment).
///
/// Display controls (grid/list, sort, hidden files, files/folders filter)
/// aren't kept here - [items] runs the listing through the Files tab's own
/// [FilesController] prefs so the two tabs always agree.
class OfflineController extends ChangeNotifier
with WidgetsBindingObserver
implements FolderBrowser {
final SessionController session;
final FilesController files;
List<String> _pathStack = const ['/'];
List<NextcloudItem> _items = [];
bool _isLoading = false;
String? _errorMessage;
bool _wasSyncing = false;
// Resolved once in _load - lets [localFileFor] stay synchronous so it can
// be called straight from a widget's build.
String? _syncBasePath;
StreamSubscription<SyncStatusSnapshot>? _statusSub;
OfflineController(this.session, this.files) {
WidgetsBinding.instance.addObserver(this);
session.addAccountClearedListener(_onAccountCleared);
session.addAccountReadyListener(_onAccountActivated);
// Refreshes automatically once a sync pass finishes, so newly
// downloaded files show up here without a manual pull-to-refresh.
_statusSub = SyncService.statusStream.listen(_onStatusSnapshot);
}
@override
List<String> get pathStack => _pathStack;
@override
String get currentFolderPath => _pathStack.last;
@override
List<NextcloudItem> get items => files.applyFilesDisplayPrefs(
_items,
folderPath: currentFolderPath,
// The local mirror has no cloud/external distinction to filter on.
applyStorageScope: false,
);
// Only "loading" while there's nothing to show yet - a reload over an
// already-listed folder (e.g. the automatic one after a sync pass
// finishes) swaps the list in place instead of flashing a spinner.
@override
bool get isLoading => _isLoading && _items.isEmpty;
@override
String? get errorMessage => _errorMessage;
void _onAccountCleared() {
_pathStack = const ['/'];
_items = [];
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
void _onAccountActivated() {
unawaited(_load());
}
/// A background sync (which can run while the app is closed or paused)
/// may have added or removed files since this list was last read - re-read
/// the folder whenever the app comes back to the foreground.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) unawaited(_load());
}
void _onStatusSnapshot(SyncStatusSnapshot snapshot) {
if (_wasSyncing && !snapshot.syncing) {
unawaited(_load());
}
_wasSyncing = snapshot.syncing;
}
/// Reloads the currently browsed folder - used by pull-to-refresh and as
/// the account-activation entry point.
@override
Future<void> reload() => _load();
@override
Future<void> navigateToFolder(String path) async {
_pathStack = [..._pathStack, path];
await _load();
}
@override
Future<void> navigateToPathIndex(int index) async {
if (index < 0 || index >= _pathStack.length - 1) return;
_pathStack = _pathStack.sublist(0, index + 1);
await _load();
}
@override
Future<void> navigateUp() async {
if (_pathStack.length <= 1) return;
_pathStack = _pathStack.sublist(0, _pathStack.length - 1);
await _load();
}
Future<void> _load() async {
final accountId = session.activeAccountId;
if (accountId == null) return;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
// A sync can delete the very folder being browsed (deleted on the
// server) - step out to the nearest ancestor that still exists rather
// than showing a phantom empty folder.
final base = _syncBasePath;
if (base != null) {
while (_pathStack.length > 1 &&
!Directory(
p.join(
base,
'sync',
accountId,
currentFolderPath.replaceFirst('/', ''),
),
).existsSync()) {
_pathStack = _pathStack.sublist(0, _pathStack.length - 1);
}
}
_items = await _listFolder(accountId, currentFolderPath);
} catch (e) {
debugPrint('[OfflineController] Error listing local files: $e');
_errorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// The absolute local path for [item] - a pure function of its remote-
/// style `path` (same `<accountId>/<relPath>` layout `SyncEngine.kt#
/// syncRoot` uses). Deliberately doesn't re-verify [item] is "in sync
/// scope" the way `SyncStatusController.localSyncedFilePath` does for
/// arbitrary items from elsewhere in the app - every item this
/// controller ever hands out already came from listing this exact
/// directory tree, so it's definitionally already local; re-checking
/// scope here was the actual bug behind a file the Offline tab had just
/// listed reporting itself as "no longer available".
/// Synchronous counterpart to [localPathFor] for building thumbnails -
/// null until the first [_load] has resolved the storage directory. Doesn't
/// check the file exists; the image widget's own error fallback covers a
/// missing one.
File? localFileFor(NextcloudItem item) {
final accountId = session.activeAccountId;
final base = _syncBasePath;
if (accountId == null || base == null) return null;
final relPath = item.path.startsWith('/')
? item.path.substring(1)
: item.path;
return File(p.join(base, 'sync', accountId, relPath));
}
Future<String?> localPathFor(NextcloudItem item) async {
final accountId = session.activeAccountId;
if (accountId == null) 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', accountId, relPath));
return file.existsSync() ? file.path : null;
}
static Future<List<NextcloudItem>> _listFolder(
String accountId,
String folderPath,
) async {
final base = await getExternalStorageDirectory();
if (base == null) return [];
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
if (!dir.existsSync()) return [];
final items = <NextcloudItem>[];
await for (final entity in dir.list(followLinks: false)) {
final name = p.basename(entity.path);
final remotePath = folderPath == '/' ? '/$name' : '$folderPath/$name';
if (entity is Directory) {
items.add(
NextcloudItem(
id: remotePath,
name: name,
path: remotePath,
type: NextcloudItemType.folder,
size: 0,
lastModified: (await entity.stat()).modified,
),
);
} else if (entity is File) {
final stat = await entity.stat();
items.add(
NextcloudItem(
id: remotePath,
name: name,
path: remotePath,
type: NextcloudItem.deduceType(remotePath, false, null),
size: stat.size,
lastModified: stat.modified,
),
);
}
}
return items;
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_statusSub?.cancel();
super.dispose();
}
}
+199
View File
@@ -0,0 +1,199 @@
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<NextcloudItem> _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<NextcloudItem> 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<void> _onAccountActivated() async {
final id = session.activeAccountId;
if (id != null) {
// Wrapped so a single bad/mistyped stored pref value can't
// silently skip fetchAllMedia() below and leave Photos permanently
// empty on this activation with no error surfaced anywhere - see
// FilesController._onAccountActivated's identical guard for the
// real incident this mirrors.
try {
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();
} catch (e) {
debugPrint('[PhotosController] Account-activation prefs restore failed: $e');
}
}
unawaited(fetchAllMedia());
}
/// Loads every image/video across the whole account for the Photos tab.
Future<void> 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<void> 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();
}
}
+95
View File
@@ -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<bool> confirmPick(List<NextcloudItem> 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 = <String>[];
final mimeTypes = <String>[];
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<void> cancelPick() async {
if (_pickRequest == null) return;
_pickRequest = null;
notifyListeners();
await PickIntentService.cancelPick();
}
}
+59
View File
@@ -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<NextcloudItem> _items = [];
bool _isLoading = false;
String? _errorMessage;
RecentController(this.session) {
session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated);
}
List<NextcloudItem> get items => _items;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
void _onAccountCleared() {
_items = [];
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
void _onAccountActivated() {
unawaited(fetchAll());
}
Future<void> 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();
}
}
}
}
File diff suppressed because it is too large Load Diff
+689
View File
@@ -0,0 +1,689 @@
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';
import 'connectivity_controller.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 ConnectivityController connectivity;
final Future<SharedPreferences> prefsFuture =
SharedPreferences.getInstance();
final AccountStore accountStore = AccountStore();
// True from the moment a saved session is restored (or a network
// failure keeps it alive - see `_applyCredentialsForAccount`) without
// ever having actually reached the server, until a real
// `testConnection()` succeeds. Network-fetching controllers have no
// reason to check this themselves - they only ever hear about account
// activation via [addAccountActivatedListener], which simply doesn't
// fire while this is true (see [addAccountReadyListener] for the
// signal that *does* fire either way).
bool _isProvisionalLogin = false;
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<SavedAccount> _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 against the server, so it's safe to make network
// requests (fetch your own data now) - it deliberately does *not* fire
// for a provisional/offline login (see `_isProvisionalLogin`), so a
// network-fetching controller never has to check connectivity itself.
// `ready` fires in *both* cases - verified or provisional - for the
// handful of controllers (SyncStatusController/OfflineController) whose
// own activation work is local-only (prefs, on-disk files) and safe to
// run with no connection at all.
final List<VoidCallback> _accountClearedListeners = [];
final List<VoidCallback> _accountActivatedListeners = [];
final List<VoidCallback> _accountReadyListeners = [];
void addAccountClearedListener(VoidCallback cb) =>
_accountClearedListeners.add(cb);
void addAccountActivatedListener(VoidCallback cb) =>
_accountActivatedListeners.add(cb);
void addAccountReadyListener(VoidCallback cb) =>
_accountReadyListeners.add(cb);
void _notifyAccountCleared() {
for (final cb in _accountClearedListeners) {
cb();
}
}
void _notifyAccountActivated() {
for (final cb in _accountActivatedListeners) {
cb();
}
for (final cb in _accountReadyListeners) {
cb();
}
}
void _notifyAccountProvisionallyReady() {
for (final cb in _accountReadyListeners) {
cb();
}
}
SessionController(this.connectivity) {
WidgetsBinding.instance.addObserver(this);
connectivity.addListener(_onConnectivityChanged);
_init();
}
Future<void> _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<SavedAccount> 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<void> _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<void> _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. With no network route at all, or on
/// a network-level (not auth) failure, skips/gives up on verification
/// but still logs in *provisionally* - see [_isProvisionalLogin] - so
/// the app still opens into `MainShellView` (restricted to the Offline
/// tab while offline) instead of bouncing to `LoginView`. 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 (an actual 401, never a network failure).
Future<bool> _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,
);
// No point even attempting the request with no network route at all -
// treat this exactly like the network-failure branch below (stay
// logged in provisionally, `service` already assigned) without paying
// for a doomed HTTP call first.
if (connectivity.isOffline) {
if (gen != _sessionGeneration) return false;
_isProvisionalLogin = true;
_isLoggedIn = true;
_isLoading = false;
notifyListeners();
_notifyAccountProvisionallyReady();
return true;
}
try {
final success = await _service!.testConnection();
if (gen != _sessionGeneration) return false;
if (success) {
_isProvisionalLogin = false;
_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 - and only actually log out - on an
// actual auth rejection (401). Every other failure (DNS, timeout,
// unreachable host, a transient 5xx) is provisional: staying
// "logged in" with the cached account/service lets the app still
// land on MainShellView instead of bouncing to LoginView, which
// would strand a genuinely offline user with no way back in short
// of Login Flow v2 again (activeAccountId is never cleared and
// switchAccount() no-ops when asked to "switch" to the account
// that's already nominally active). `_service` is already assigned
// above and needs no re-creation once connectivity returns.
if (_errorMessage?.contains('401') == true) {
await accountStore.deletePassword(account.id);
_isProvisionalLogin = false;
_isLoggedIn = false;
_service = null;
_isLoading = false;
notifyListeners();
return false;
}
_isProvisionalLogin = true;
_isLoggedIn = true;
_isLoading = false;
notifyListeners();
_notifyAccountProvisionallyReady();
return true;
}
if (gen != _sessionGeneration) return false;
_isProvisionalLogin = false;
_isLoggedIn = false;
_service = null;
_isLoading = false;
notifyListeners();
return false;
}
/// Once connectivity actually returns, re-verifies a provisional/offline
/// login for real - this is what finally fires [_notifyAccountActivated]
/// (not just [_notifyAccountProvisionallyReady]) so network-fetching
/// controllers get their first real fetch without requiring an app
/// restart.
void _onConnectivityChanged() {
_reverifyTimer?.cancel();
if (!_isProvisionalLogin || connectivity.isOffline) return;
unawaited(_reverify(0));
}
Timer? _reverifyTimer;
/// One re-verification attempt, retried with a growing delay if the
/// server still can't be reached. The OS reports "connected" a moment
/// before the route actually works (the first request right after
/// regaining Wi-Fi/data fails with "Network is unreachable"), and
/// `onConnectivityChanged` won't fire again to give a second chance - so
/// without retrying, the login stayed provisional and the Files tab showed
/// a connection error until the user tapped Retry.
Future<void> _reverify(int attempt) async {
if (!_isProvisionalLogin || connectivity.isOffline) return;
final id = _activeAccountId;
if (id == null) return;
final account = _accounts.where((a) => a.id == id).firstOrNull;
if (account == null) return;
await _applyCredentialsForAccount(account, _password);
if (_isProvisionalLogin && !connectivity.isOffline && attempt < 5) {
_reverifyTimer = Timer(
Duration(seconds: 2 * (attempt + 1)),
() => unawaited(_reverify(attempt + 1)),
);
}
}
/// 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<void> 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<void> _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<void> _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);
// Signed back in (or switched to) - it syncs in the background again.
await accountStore.setSignedOut(prefs, accountId, false);
notifyListeners();
final account = _accounts.where((a) => a.id == accountId).firstOrNull;
final password = account == null
? null
: await accountStore.readPassword(accountId);
if (account == null || password == null) {
_isProvisionalLogin = false;
_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<bool> 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<SavedAccount?> _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<SavedAccount?> cycleToNextAccount() => _cycleAccount(1);
Future<SavedAccount?> 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<void> 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));
}
await accountStore.setSignedOut(prefs, accountId, false);
// Its background sync job holds that account's credentials - stop it.
unawaited(SyncService.cancelAccount(accountId));
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<void> _deactivateSession() async {
_sessionGeneration++;
_notifyAccountCleared();
_isProvisionalLogin = false;
_isLoggedIn = false;
_serverUrl = '';
_username = '';
_password = '';
_activeAccountId = null;
final prefs = await prefsFuture;
await accountStore.saveActiveAccountId(prefs, null);
notifyListeners();
}
/// 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<void> logout() async {
final id = _activeAccountId;
if (id == null) return;
// Logging out stops that account's background sync (the other saved
// accounts keep syncing - each has its own job). It's remembered as
// signed out so a later reschedule for some other account doesn't
// bring its job back; signing in again re-enables it.
final prefs = await prefsFuture;
await accountStore.setSignedOut(prefs, id, true);
await _deactivateSession();
unawaited(SyncService.cancelAccount(id));
}
/// 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<bool> 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<bool> 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<bool> 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<bool> passGate(bool gate, String reason) async {
if (!_loginLockEnabled || !gate) return true;
return AppLockService.authenticate(reason);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
connectivity.removeListener(_onConnectivityChanged);
_reverifyTimer?.cancel();
_pollTimer?.cancel();
_pollTimeoutTimer?.cancel();
super.dispose();
}
}
+327
View File
@@ -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<SharedPreferences> _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<AppTab> _tabOrder = AppTab.values.toList();
Set<AppTab> _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<AppTab> get tabOrder => _tabOrder;
Set<AppTab> get hiddenTabs => _hiddenTabs;
AppTab get defaultTab => _defaultTab;
/// The tabs the bottom nav bar should actually show, in order.
List<AppTab> get visibleTabs =>
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
SwipeAction get swipeLeftAction => _swipeLeftAction;
SwipeAction get swipeRightAction => _swipeRightAction;
Future<void> _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 = <AppTab>[];
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<AppTab>()
.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<AppTab> 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));
}
}
+81
View File
@@ -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<NextcloudShare> _shares = [];
bool _isLoading = false;
String? _errorMessage;
bool _sharedWithMe = false;
SharesController(this.session) {
session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated);
}
List<NextcloudShare> 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<void> 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<bool> 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;
}
}
+573
View File
@@ -0,0 +1,573 @@
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 'files_controller.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.
///
/// **Keeping synced files current.** Synced folders/files follow the same
/// Files Cache rule (Settings) as folder listings, so nothing needs the
/// "Sync now" button:
///
/// - *Refresh periodically (N min)*: a WorkManager periodic job runs in the
/// background at that interval (Android's floor is 15 min, so shorter
/// values only apply in the foreground), and while the app's open an
/// in-app timer runs a pass every N minutes plus one on resume if it's
/// been longer than that.
/// - *Never cache*: no background schedule; a pass runs each time the app
/// is opened/resumed.
/// - *Refresh manually only*: only pull-to-refresh (any tab's sync header)
/// and "Sync now".
///
/// Every automatic pass is cheap when nothing changed - the native worker
/// checks each synced root's etag (Nextcloud propagates any descendant
/// change up to every ancestor's etag) and skips the full walk if it's
/// unchanged and the local copy is intact. There's no server push: Nextcloud's
/// only push channels (the `notify_push` server app, or FCM via Nextcloud's
/// push proxy) need server-side setup and a persistent connection/registered
/// app identity, so this polls instead.
class SyncStatusController extends ChangeNotifier with WidgetsBindingObserver {
final SessionController session;
final FilesController files;
static const prefSyncedPaths = 'ui_synced_folders';
static const _prefSyncedPathTypes = 'ui_synced_folder_types';
static const prefSyncEverything = 'ui_sync_everything';
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
static const _prefSyncNotifications = 'ui_sync_notifications';
List<String> _syncedPaths = [];
// path -> isFolder, keyed the same as [_syncedPaths] - missing entries
// (from before this map existed) default to folder, the overwhelmingly
// common case.
Map<String, bool> _syncedPathTypes = {};
bool _syncEverything = false;
bool _syncOnCellular = false;
bool _syncNotifications = true;
bool _isSyncingNow = false;
Set<String> _syncingFileIds = {};
Set<String> _syncedFileIds = {};
List<SyncConflictInfo> _syncConflicts = [];
StreamSubscription<SyncStatusSnapshot>? _statusSub;
// Gates addSyncedPaths/removeSyncedPaths (anything that persists
// _syncedPaths) against running before _onAccountActivated's async
// prefs load has actually finished - without this, syncing a folder
// right after opening the app could race the load, overwrite the
// persisted list with just the one path being added/removed, and
// silently destroy every previously-synced folder. Deliberately starts
// *incomplete*, not pre-completed - a mutation is only ever reachable
// from Files/Offline UI, which requires a completed activation already
// (Files' own item list depends on the same login event), so there's no
// real deadlock risk, and starting complete would leave the very first
// cold-start load completely unprotected (only account *switches*
// re-arm it, via _onAccountCleared - the bug that actually caused
// repeated data loss here).
Completer<void> _accountLoadedGate = Completer<void>();
// True once _syncedPaths/_syncedPathTypes/_syncEverything have been
// loaded from prefs for the *current* account - _onAccountActivated can
// fire more than once per account (e.g. SessionController re-verifying
// a provisional/offline login once connectivity returns), and a second
// pass re-reading from storage could clobber an in-memory mutation made
// between the first load and the second one if its own persist hadn't
// landed yet. Reset in _onAccountCleared.
bool _hasLoadedSyncedPathsForAccount = false;
Timer? _autoSyncTimer;
bool _foreground = true;
DateTime? _lastAutoSyncAt;
CachePolicy? _lastPolicy;
int? _lastInterval;
SyncStatusController(this.session, this.files) {
WidgetsBinding.instance.addObserver(this);
files.addListener(_onFilesChanged);
session.addAccountClearedListener(_onAccountCleared);
// `ready`, not `activated` - this controller's own activation work
// (loading prefs, calling the native sync channel) is local/native
// only, so it's safe to run even on a provisional/offline login (see
// `SessionController.addAccountReadyListener`'s doc comment).
session.addAccountReadyListener(_onAccountActivated);
_statusSub = SyncService.statusStream.listen(_applySnapshot);
_loadGlobalPrefs();
}
List<String> get syncedPaths => List.unmodifiable(_syncedPaths);
bool get syncEverything => _syncEverything;
bool get syncOnCellular => _syncOnCellular;
/// Whether background (automatic) sync runs may post progress/summary
/// notifications. Conflicts and user-initiated "Sync now" always notify.
bool get syncNotifications => _syncNotifications;
bool get isSyncingNow => _isSyncingNow;
bool get _hasSyncScope => _syncEverything || _syncedPaths.isNotEmpty;
/// How often the background WorkManager job should run, or null for no
/// background sync at all - only the "refresh periodically" Files Cache
/// policy schedules one (see the class doc comment).
int? get backgroundSyncIntervalMinutes =>
files.cachePolicy == CachePolicy.interval
? files.cacheIntervalMinutes
: null;
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
/// How many of [syncedPaths] are folders (as opposed to individual
/// files) - used by the sync header's expanded summary.
int get syncedFolderCount =>
_syncedPaths.where((p) => _syncedPathTypes[p] ?? true).length;
SyncHeaderStatus get syncHeaderStatus {
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
if (_isSyncingNow) return SyncHeaderStatus.syncing;
if (!_syncEverything && _syncedPaths.isEmpty) return SyncHeaderStatus.off;
return SyncHeaderStatus.done;
}
/// True if [path] itself, or an ancestor of it, is covered by device
/// sync - either explicitly (one of [_syncedPaths]) or via
/// [_syncEverything]. Shared by [syncStatusFor] (folders) and
/// [localSyncedFilePath] (files).
bool _isPathInSyncScope(String path) {
if (_syncEverything) return true;
final normalized = path.endsWith('/')
? path.substring(0, path.length - 1)
: path;
return _syncedPaths.any((folder) {
final f = folder.endsWith('/')
? folder.substring(0, folder.length - 1)
: folder;
return normalized == f || normalized.startsWith('$f/');
});
}
/// True if any pending conflict falls under [folderPath].
bool _folderHasConflict(String folderPath) {
final normalized = folderPath.endsWith('/')
? folderPath.substring(0, folderPath.length - 1)
: folderPath;
return _syncConflicts.any((c) {
final p = c.remotePath.endsWith('/')
? c.remotePath.substring(0, c.remotePath.length - 1)
: c.remotePath;
return p == normalized || p.startsWith('$normalized/');
});
}
/// Folders don't get their own entry in [_syncedFileIds] (only individual
/// files do - see `SyncEngine.diffFolder`'s `if (entry.isFolder) continue`),
/// so a folder's status is derived from whether it's in sync scope at all
/// rather than tracked per-item like a file's is.
SyncItemStatus syncStatusFor(NextcloudItem item) {
if (item.isFolder) {
if (!_isPathInSyncScope(item.path)) return SyncItemStatus.none;
if (_folderHasConflict(item.path)) return SyncItemStatus.conflict;
if (_isSyncingNow) return SyncItemStatus.syncing;
return SyncItemStatus.synced;
}
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<void> _loadGlobalPrefs() async {
final prefs = await session.prefsFuture;
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
_syncNotifications =
prefs.getBool(_prefSyncNotifications) ?? _syncNotifications;
notifyListeners();
}
void _onAccountCleared() {
// Re-armed (not completed) here, not just at construction - an
// account switch means the *next* activation's load has to finish
// before any mutation touching _syncedPaths is safe again. Only
// replace the Completer if nothing's still awaiting the old one -
// swapping it out from under a pending awaiter would orphan that
// await forever, since nothing would ever complete the discarded
// instance.
if (_accountLoadedGate.isCompleted) _accountLoadedGate = Completer<void>();
_hasLoadedSyncedPathsForAccount = false;
_syncedPaths = [];
_syncedPathTypes = {};
_syncEverything = false;
_isSyncingNow = false;
_syncingFileIds = {};
_syncedFileIds = {};
_syncConflicts = [];
_autoSyncTimer?.cancel();
_lastAutoSyncAt = null;
_lastPolicy = null;
_lastInterval = null;
notifyListeners();
}
Future<void> _onAccountActivated() async {
final id = session.activeAccountId;
// Only load _syncedPaths/_syncedPathTypes/_syncEverything once per
// account, not on every activation - this can fire more than once
// for the same account (e.g. SessionController re-verifying a
// provisional/offline login once connectivity returns), and a second
// read from storage could clobber an in-memory mutation made between
// the first load and this one if its own persist hadn't landed yet.
// See _hasLoadedSyncedPathsForAccount's doc comment.
if (id != null && !_hasLoadedSyncedPathsForAccount) {
_hasLoadedSyncedPathsForAccount = true;
// Wrapped so a single bad/mistyped stored pref value can't
// propagate uncaught out of this method and skip the gate
// completion below - that would leave every future
// addSyncedPaths/removeSyncedPaths call awaiting a gate that never
// completes, hanging forever. See FilesController.
// _onAccountActivated's identical guard for the real incident this
// mirrors.
try {
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<String>();
} catch (e) {
debugPrint('[SyncStatusController] Synced paths restore failed: $e');
_syncedPaths = [];
}
} else {
_syncedPaths = [];
}
final typesJson = prefs.getString(k(_prefSyncedPathTypes));
if (typesJson != null) {
try {
_syncedPathTypes = (jsonDecode(typesJson) as Map).map(
(k, v) => MapEntry(k as String, v as bool),
);
} catch (e) {
debugPrint('[SyncStatusController] Synced path types restore failed: $e');
_syncedPathTypes = {};
}
} else {
_syncedPathTypes = {};
}
_syncEverything = prefs.getBool(k(prefSyncEverything)) ?? false;
notifyListeners();
} catch (e) {
debugPrint('[SyncStatusController] Account-activation prefs restore failed: $e');
}
}
// Only now is it safe for addSyncedPaths/removeSyncedPaths to mutate
// and persist _syncedPaths - see _accountLoadedGate's doc comment.
if (!_accountLoadedGate.isCompleted) _accountLoadedGate.complete();
unawaited(SyncService.getStatus(id).then(_applySnapshot));
// Scheduling depends on the Files Cache policy, so wait for it to load
// rather than scheduling from the defaults and correcting a moment later.
await files.displayPrefsLoaded;
_lastPolicy = files.cachePolicy;
_lastInterval = files.cacheIntervalMinutes;
unawaited(SyncService.reschedule(session, this));
_restartAutoSyncTimer();
if (_dueForAutoSync) unawaited(_autoSync());
}
// ---- Automatic sync (see the class doc comment) ----
void _onFilesChanged() {
// FilesController notifies on every navigation - only the cache rule
// itself matters here.
if (_lastPolicy == null) return; // account not activated yet
final policy = files.cachePolicy;
final interval = files.cacheIntervalMinutes;
if (policy == _lastPolicy && interval == _lastInterval) return;
_lastPolicy = policy;
_lastInterval = interval;
unawaited(SyncService.reschedule(session, this));
_restartAutoSyncTimer();
}
/// Whether an app-open/resume should kick off a pass under the current
/// cache rule.
bool get _dueForAutoSync {
switch (files.cachePolicy) {
case CachePolicy.never:
return true;
case CachePolicy.manual:
return false;
case CachePolicy.interval:
final last = _lastAutoSyncAt;
return last == null ||
DateTime.now().difference(last) >=
Duration(minutes: files.cacheIntervalMinutes);
}
}
void _restartAutoSyncTimer() {
_autoSyncTimer?.cancel();
if (!_foreground ||
!_hasSyncScope ||
files.cachePolicy != CachePolicy.interval) {
return;
}
_autoSyncTimer = Timer.periodic(
Duration(minutes: files.cacheIntervalMinutes),
(_) => unawaited(_autoSync()),
);
}
/// A quiet, network-setting-respecting pass - see [SyncService.syncNow].
Future<void> _autoSync() async {
if (!_canSyncQuietly) return;
_lastAutoSyncAt = DateTime.now();
try {
await SyncService.syncNow(
session,
this,
force: false,
respectNetworkSetting: true,
);
} catch (e) {
debugPrint('[SyncStatusController] Automatic sync failed: $e');
}
}
/// A deliberate pull-to-refresh (any tab's sync header): check for server
/// changes now, regardless of the cache rule or the Wi-Fi-only setting.
Future<void> syncOnPull() async {
if (!_canSyncQuietly) return;
try {
await SyncService.syncNow(session, this, force: false);
} catch (e) {
debugPrint('[SyncStatusController] Pull-triggered sync failed: $e');
}
}
bool get _canSyncQuietly =>
_hasSyncScope &&
!_isSyncingNow &&
session.isLoggedIn &&
!session.connectivity.isOffline;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_foreground = true;
_restartAutoSyncTimer();
if (_dueForAutoSync) unawaited(_autoSync());
} else if (state == AppLifecycleState.paused) {
// The in-app timer only runs while foregrounded; WorkManager owns the
// background schedule.
_foreground = false;
_autoSyncTimer?.cancel();
}
}
void _applySnapshot(SyncStatusSnapshot snapshot) {
// A null accountId is the bus's initial "nothing has synced in this
// process yet" emission on subscribe - applying it would wipe the
// synced-file ids seeded by getStatus(accountId).
if (snapshot.accountId == null ||
snapshot.accountId != session.activeAccountId) {
return;
}
_isSyncingNow = snapshot.syncing;
_syncingFileIds = snapshot.syncingFileIds;
_syncedFileIds = snapshot.syncedFileIds;
_syncConflicts = snapshot.conflicts;
notifyListeners();
// A synced file/folder deleted on the server has nothing left to sync -
// drop it from the synced list rather than leaving a dead entry.
final gone = snapshot.missingRoots.where(_syncedPaths.contains).toList();
if (gone.isNotEmpty) unawaited(removeSyncedPaths(gone));
}
void _persistSyncedPaths() {
final id = session.activeAccountId;
if (id == null) return;
session.prefsFuture.then((p) {
p.setString(
session.accountStore.accountPrefKey(id, prefSyncedPaths),
jsonEncode(_syncedPaths),
);
p.setString(
session.accountStore.accountPrefKey(id, _prefSyncedPathTypes),
jsonEncode(_syncedPathTypes),
);
});
}
bool isPathSynced(String path) => _syncedPaths.contains(path);
Future<void> addSyncedPath(String path, {required bool isFolder}) {
return addSyncedPaths({path: isFolder});
}
/// Adds every path in [entries] (path -> isFolder) to sync in one go -
/// unlike calling [addSyncedPath] once per item in a loop, this touches
/// `_syncedPaths`/persists/reschedules/triggers an immediate sync
/// exactly once no matter how many paths are being added. Looping
/// `addSyncedPath` from a multi-select "Sync to device" was the actual
/// bug behind only the first selected item ever actually syncing: each
/// call fired its own `SyncService.syncNow`, and WorkManager's
/// `enqueueUniqueWork(..., ExistingWorkPolicy.REPLACE, ...)` policy
/// meant each later call cancelled the previous item's still-in-flight
/// sync pass (and could stomp on its `SyncEngine.saveState`, which
/// overwrites the whole state map rather than merging) instead of
/// letting it finish.
Future<void> addSyncedPaths(Map<String, bool> entries) async {
// Must not mutate/persist _syncedPaths before the per-account load
// has actually populated it - see _accountLoadedGate's doc comment.
await _accountLoadedGate.future;
var changed = false;
entries.forEach((path, isFolder) {
if (_syncedPaths.contains(path)) return;
_syncedPaths = [..._syncedPaths, path];
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
changed = true;
});
if (!changed) return;
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(session, this));
_restartAutoSyncTimer();
// `reschedule` alone only lines up the *periodic* job, which can be up
// to an hour away from its first run - without an immediate one-off
// sync here too, newly-added items would just sit unsynced until the
// user thought to trigger one manually (e.g. pulling to refresh on
// the Offline tab).
unawaited(_syncNowSilently());
}
/// Unsyncs [path] and deletes its already-downloaded local mirror (see
/// `SyncService.removeLocalSync`) - stopping sync alone would leave
/// whatever had already been downloaded sitting on disk indefinitely.
Future<void> removeSyncedPath(String path) {
return removeSyncedPaths([path]);
}
/// Batched counterpart to [removeSyncedPath] - see [addSyncedPaths]'s
/// doc comment for why a multi-item loop of individual calls is unsafe
/// for `reschedule`/native side effects, even though removal itself
/// (unlike adding) doesn't trigger a one-off sync to race.
Future<void> removeSyncedPaths(List<String> paths) async {
await _accountLoadedGate.future;
var changed = false;
for (final path in paths) {
if (!_syncedPaths.contains(path)) continue;
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
changed = true;
}
if (!changed) return;
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(session, this));
_restartAutoSyncTimer();
for (final path in paths) {
unawaited(SyncService.removeLocalSync(session, path));
}
}
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));
_restartAutoSyncTimer();
if (value) unawaited(_syncNowSilently());
}
void setSyncNotifications(bool value) {
if (_syncNotifications == value) return;
_syncNotifications = value;
notifyListeners();
session.prefsFuture.then((p) => p.setBool(_prefSyncNotifications, value));
// Baked into each periodic job's input, so the jobs have to be
// re-enqueued for the change to reach background runs.
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));
}
/// Fire-and-forget immediate sync, swallowing errors - called right
/// after turning sync on for something (see [addSyncedPath]/
/// [setSyncEverything]) so it actually starts syncing now instead of
/// only ever running on the next periodic pass or a manual "Sync now".
Future<void> _syncNowSilently() async {
try {
await SyncService.syncNow(session, this);
} catch (e) {
debugPrint('[SyncStatusController] Immediate sync failed: $e');
}
}
/// 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<void> 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 `<externalFilesDir>/sync/<accountId>/...`
/// 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<String?> localSyncedFilePath(NextcloudItem item) async {
final id = session.activeAccountId;
if (id == null) return null;
if (!_isPathInSyncScope(item.path)) return null;
return SyncService.localSyncedFilePath(id, item.path);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
files.removeListener(_onFilesChanged);
_autoSyncTimer?.cancel();
_statusSub?.cancel();
super.dispose();
}
}
+84
View File
@@ -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<NextcloudItem> _items = [];
bool _isLoading = false;
String? _errorMessage;
TrashController(this.session) {
session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated);
}
List<NextcloudItem> get items => _items;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
void _onAccountCleared() {
_items = [];
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
void _onAccountActivated() {
unawaited(fetchAll());
}
Future<void> 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<bool> 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<bool> 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;
}
}
+20
View File
@@ -20,6 +20,7 @@ class AccountStore {
static const _prefAccountsList = 'accounts_list';
static const _prefActiveAccountId = 'active_account_id';
static const _prefMigrationDone = 'account_migration_v1_done';
static const _prefSignedOutAccounts = 'signed_out_account_ids';
/// The per-account browsing prefs that get namespaced under
/// `acct_<id>_<key>` on migration/save - everything else (theme, tab
@@ -38,6 +39,7 @@ class AccountStore {
'ui_cache_policy',
'ui_cache_interval_minutes',
'ui_synced_folders',
'ui_synced_folder_types',
'ui_sync_everything',
];
@@ -129,6 +131,24 @@ class AccountStore {
}
}
/// Accounts the user explicitly logged out of. They stay saved (password
/// and prefs intact, resumable from the login screen) but are treated as
/// signed out - notably, they don't sync in the background. Cleared again
/// the moment the account is activated.
Set<String> loadSignedOut(SharedPreferences prefs) =>
(prefs.getStringList(_prefSignedOutAccounts) ?? const []).toSet();
Future<void> setSignedOut(
SharedPreferences prefs,
String accountId,
bool signedOut,
) async {
final current = loadSignedOut(prefs);
final changed = signedOut ? current.add(accountId) : current.remove(accountId);
if (!changed) return;
await prefs.setStringList(_prefSignedOutAccounts, current.toList());
}
Future<String?> readPassword(String accountId) =>
_storage.read(key: '$_passwordKeyPrefix$accountId');
+5 -14
View File
@@ -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<void> startDownload(
ServerProvider provider,
SessionController session,
List<NextcloudItem> 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});
}
}
+23
View File
@@ -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<String, String> baseChannelArgs(SessionController session) {
return {
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': requireAuthHeader(session),
};
}
+233 -52
View File
@@ -1,7 +1,15 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import '../models/sync_status.dart';
import '../providers/server_provider.dart';
import '../models/saved_account.dart';
import '../providers/files_controller.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`.
@@ -12,12 +20,17 @@ class SyncStatusSnapshot {
final Set<String> syncedFileIds;
final List<SyncConflictInfo> conflicts;
/// Configured sync paths the server says no longer exist (deleted on the
/// web) - the app drops them from its synced list.
final Set<String> missingRoots;
const SyncStatusSnapshot({
required this.accountId,
required this.syncing,
required this.syncingFileIds,
required this.syncedFileIds,
required this.conflicts,
this.missingRoots = const {},
});
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
@@ -34,6 +47,9 @@ class SyncStatusSnapshot {
.cast<Map<dynamic, dynamic>>()
.map(SyncConflictInfo.fromMap)
.toList(),
missingRoots: ((map['missingRoots'] as List?) ?? const [])
.cast<String>()
.toSet(),
);
}
}
@@ -43,97 +59,164 @@ 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<void> reschedule(ServerProvider provider) async {
final accountId = provider.activeAccountId;
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (accountId == null || service == null || authHeader == null) {
return cancel();
/// Brings the background periodic sync jobs in line with every saved
/// account's current settings - one job per account with sync enabled
/// (paths or "sync everything", and the Files Cache policy set to refresh
/// periodically), cancelled for accounts without and for accounts the
/// user logged out of. Background sync is deliberately not limited to the
/// active account: each account's job carries that account's own
/// credentials, paths and interval, so switching accounts doesn't stop the
/// others.
///
/// Call whenever any of that changes - a periodic `WorkRequest`'s input is
/// fixed at enqueue time and can only be updated by re-enqueueing. The
/// active account's settings come from [sync]/its live state (they may not
/// have been persisted yet); every other account's are read from storage.
static Future<void> reschedule(
SessionController session,
SyncStatusController sync,
) async {
final prefs = await session.prefsFuture;
final store = session.accountStore;
final wifiOnly = !sync.syncOnCellular;
final signedOut = store.loadSignedOut(prefs);
for (final account in session.accounts) {
try {
if (signedOut.contains(account.id)) {
await cancelAccount(account.id);
continue;
}
final isActive = account.id == session.activeAccountId;
final config = isActive
? _SyncConfig(
paths: sync.syncEverything ? ['/'] : sync.syncedPaths,
intervalMinutes: sync.backgroundSyncIntervalMinutes,
)
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
final password = await store.readPassword(account.id);
if (password == null ||
config.paths.isEmpty ||
config.intervalMinutes == null) {
await cancelAccount(account.id);
continue;
}
await _channel.invokeMethod('reschedule', {
'accountId': account.id,
'serverUrl': account.serverUrl,
'username': account.username,
'authHeader': _basicAuth(account, password),
'folders': jsonEncode(config.paths),
'wifiOnly': wifiOnly,
'intervalMinutes': config.intervalMinutes,
'notify': sync.syncNotifications,
});
} catch (e) {
debugPrint('[SyncService] Could not schedule ${account.id}: $e');
}
}
final paths = provider.syncEverything ? ['/'] : provider.syncedPaths;
await _channel.invokeMethod('reschedule', {
'accountId': accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
'wifiOnly': !provider.syncOnCellular,
});
}
static Future<void> cancel() async {
await _channel.invokeMethod('cancel');
static String _basicAuth(SavedAccount account, String password) =>
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
/// Stops [accountId]'s periodic job (the account was removed, or has
/// nothing left to sync).
static Future<void> cancelAccount(String accountId) async {
await _channel.invokeMethod('cancel', {'accountId': accountId});
}
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
/// independent of the periodic schedule.
static Future<void> syncNow(ServerProvider provider) async {
final accountId = provider.activeAccountId;
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (accountId == null || service == null || authHeader == null) {
/// Runs a one-off sync pass immediately, independent of the periodic
/// schedule.
///
/// [force] (Settings' "Sync now", a newly-added path): full walk of every
/// synced root, visible progress notification, supersedes any run already
/// in flight. Without it (pull-to-refresh, foreground timer, app resume)
/// the pass is a quiet check that uses the cheap root-etag shortcut and
/// never interrupts a run already in progress. [respectNetworkSetting]
/// makes such a pass wait for Wi-Fi when "Sync on cellular" is off (the
/// automatic triggers do; a deliberate pull doesn't).
static Future<void> syncNow(
SessionController session,
SyncStatusController sync, {
bool force = true,
bool respectNetworkSetting = false,
}) 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),
'force': force,
'notify': sync.syncNotifications,
if (respectNetworkSetting) 'wifiOnly': !sync.syncOnCellular,
});
}
/// One-shot status snapshot - used to seed [ServerProvider]'s state right
/// after login/account-switch, before the first [statusStream] event
/// arrives.
static Future<SyncStatusSnapshot> getStatus() async {
/// One-shot status snapshot - used to seed [SyncStatusController]'s state
/// right after login/account-switch, before the first [statusStream]
/// event arrives.
///
/// [accountId] is required for `syncedFileIds` to be populated on a fresh
/// app start - the native bus only knows an account once a sync pass has
/// run in this process.
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
'getSyncStatus',
{'accountId': accountId},
);
return SyncStatusSnapshot.fromMap(result ?? const {});
}
/// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing
/// started/stopped, per-file progress, new/resolved conflicts.
static Stream<SyncStatusSnapshot> get statusStream {
return _statusChannel.receiveBroadcastStream().map(
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
}
///
/// One shared stream, deliberately: every call to
/// `EventChannel.receiveBroadcastStream()` opens its *own* native
/// subscription, and the native side only keeps one listener - a second
/// subscriber (the Offline tab's controller, once it became eagerly
/// created) silently took the events away from the first
/// (`SyncStatusController`), so sync badges stopped updating until the app
/// was restarted. This is a broadcast stream, so any number of Dart
/// listeners share the one native subscription.
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
.receiveBroadcastStream()
.map(
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
/// In-app conflict resolution (the header's "Keep local"/"Use server"
/// buttons) - shares the exact same native `ConflictResolveWorker`
/// enqueue path the notification actions use, just triggered from Dart
/// instead of a `PendingIntent`.
static Future<void> 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 +224,102 @@ class SyncService {
'resolution': resolution,
});
}
/// Deletes [path]'s local mirror and its recorded sync state - called
/// when the user turns sync off for a path, so the on-device copy
/// actually goes away instead of just stopping future updates. See
/// `SyncEngine.removeLocalSync`'s doc comment for why the state also has
/// to be cleared, not just the files.
static Future<void> removeLocalSync(
SessionController session,
String path,
) async {
final accountId = session.activeAccountId;
if (accountId == null) return;
await _channel.invokeMethod('removeLocalSync', {
'accountId': accountId,
'path': path,
});
}
/// The deterministic local mirror path for [remoteItemPath] under
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
/// never needs to read native sync state. Returns null if nothing's
/// actually been synced there yet.
static Future<String?> 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;
}
}
/// One account's background-sync settings, as far as scheduling is
/// concerned.
class _SyncConfig {
final List<String> paths;
/// Null = background sync off for this account.
final int? intervalMinutes;
const _SyncConfig({required this.paths, required this.intervalMinutes});
/// Reads a *non-active* account's persisted settings - the same per-account
/// keys `SyncStatusController`/`FilesController` write. Malformed values
/// fall back to "nothing to sync" rather than throwing.
factory _SyncConfig.fromPrefs(
SharedPreferences prefs,
String Function(String accountId, String baseKey) key,
String accountId,
) {
var paths = <String>[];
try {
final everything =
prefs.getBool(
key(accountId, SyncStatusController.prefSyncEverything),
) ??
false;
final json = prefs.getString(
key(accountId, SyncStatusController.prefSyncedPaths),
);
if (everything) {
paths = ['/'];
} else if (json != null) {
paths = (jsonDecode(json) as List).cast<String>();
}
} catch (e) {
debugPrint('[SyncService] Unreadable sync paths for $accountId: $e');
}
var policy = defaultCachePolicy;
var minutes = defaultCacheIntervalMinutes;
try {
final policyName = prefs.getString(
key(accountId, FilesController.prefCachePolicy),
);
policy = CachePolicy.values.firstWhere(
(c) => c.name == policyName,
orElse: () => defaultCachePolicy,
);
minutes =
prefs.getInt(
key(accountId, FilesController.prefCacheIntervalMinutes),
) ??
defaultCacheIntervalMinutes;
} catch (e) {
debugPrint('[SyncService] Unreadable cache rule for $accountId: $e');
}
return _SyncConfig(
paths: paths,
intervalMinutes: policy == CachePolicy.interval ? minutes : null,
);
}
}
+12 -14
View File
@@ -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<void> startUpload(
ServerProvider provider,
List<SharedFileRef> files,
SessionController session,
FilesController files,
List<SharedFileRef> 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,
});
}
}
+175 -159
View File
@@ -2,9 +2,13 @@ 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/app_tab_view_builder.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart';
import '../widgets/synced_header_scaffold.dart' show formatQuota;
@@ -17,8 +21,10 @@ class AccountView extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final quota = provider.quota;
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final files = context.watch<FilesController>();
final quota = files.quota;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
@@ -46,7 +52,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 +67,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 +125,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 +191,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 +206,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),
),
],
),
@@ -249,6 +255,25 @@ class AccountView extends StatelessWidget {
const _DeviceSyncCard(),
const SizedBox(height: 24),
// Files Cache
Text(
'Files Cache',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'How often folder listings and synced files refresh from the server',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
const _CacheSettingsCard(),
const SizedBox(height: 24),
// Material You Design Settings
Text(
'Material You Aesthetics',
@@ -271,7 +296,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 +308,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 +320,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 +368,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 +380,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 +405,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),
@@ -438,48 +463,30 @@ class AccountView extends StatelessWidget {
),
const SizedBox(height: 10),
const _MediaPlayerCard(),
const SizedBox(height: 24),
// Files Cache
Text(
'Files Cache',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Reuse a folder\'s listing instead of refetching it on every visit',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
const _CacheSettingsCard(),
],
),
),
);
}
/// 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<void> _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<void> _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<bool>(
context: context,
builder: (dialogContext) {
@@ -487,8 +494,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 +514,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 +536,10 @@ class _AccountsCard extends StatelessWidget {
Future<void> _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<bool>(
context: context,
@@ -540,7 +547,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 +570,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 +584,8 @@ class _AccountsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final accounts = provider.accounts;
final session = context.watch<SessionController>();
final accounts = session.accounts;
return Card(
child: Column(
@@ -586,11 +593,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 +678,12 @@ class _SecurityCard extends StatelessWidget {
Future<void> _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 +700,7 @@ class _SecurityCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
return Card(
child: Column(
@@ -704,18 +711,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 +730,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 +751,13 @@ class _DeviceSyncCard extends StatefulWidget {
class _DeviceSyncCardState extends State<_DeviceSyncCard> {
bool _syncingNow = false;
Future<void> _syncNow(ServerProvider provider) async {
Future<void> _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(
@@ -764,11 +774,10 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final folders = provider.syncedPaths;
final everything = provider.syncEverything;
final session = context.watch<SessionController>();
final sync = context.watch<SyncStatusController>();
final folders = sync.syncedPaths;
final everything = sync.syncEverything;
return Card(
child: Column(
@@ -781,50 +790,46 @@ 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)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Every folder in this account is being synced to this '
'device.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
ListTile(
leading: const Icon(Icons.offline_pin_rounded),
title: const Text('View offline files'),
subtitle: Text(
everything
? 'Every folder in this account is synced'
: folders.isEmpty
? 'Nothing synced yet'
: '${folders.length} synced folder${folders.length == 1 ? '' : 's'}/file${folders.length == 1 ? '' : 's'}',
),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
Scaffold(body: buildAppTabView(AppTab.offline, ScrollController())),
),
)
else if (folders.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'No folders synced yet - select a folder in Files and use '
'"Sync to device" to mirror it here for offline access.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
else
for (final folder in folders) ...[
ListTile(
leading: const Icon(Icons.sync_rounded),
title: Text(folder),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing',
onPressed: () => provider.removeSyncedPath(folder),
),
),
const Divider(height: 1, indent: 16, endIndent: 16),
],
),
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: const Text('Background sync notifications'),
subtitle: const Text(
'Show a notification when a background sync updates files. '
'Conflicts and "Sync now" always notify.',
),
value: sync.syncNotifications,
onChanged: sync.setSyncNotifications,
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
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 +845,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 +862,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 +875,9 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final opacity = _opacity ?? provider.bottomBarOpacity;
final blur = _blur ?? provider.bottomBarBlur;
final settings = context.watch<SettingsController>();
final opacity = _opacity ?? settings.bottomBarOpacity;
final blur = _blur ?? settings.bottomBarBlur;
return Card(
child: Padding(
@@ -974,7 +979,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 +999,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
min: 0,
max: 40,
onChanged: (value) => setState(() => _blur = value),
onChangeEnd: provider.setBottomBarBlur,
onChangeEnd: settings.setBottomBarBlur,
),
],
),
@@ -1075,7 +1080,7 @@ class _SwipeActionsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
return Card(
child: Padding(
@@ -1086,16 +1091,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 +1162,21 @@ class _MediaPlayerCard extends StatelessWidget {
Future<void> _openPicker(
BuildContext context,
ServerProvider provider,
SettingsController settings,
) async {
final selected = await showDialog<MediaProgressBarStyle>(
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<ServerProvider>();
final settings = context.watch<SettingsController>();
return Card(
child: ListTile(
@@ -1182,17 +1187,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),
),
);
}
@@ -1342,11 +1347,14 @@ class _CacheSettingsCard extends StatelessWidget {
String _description(CachePolicy policy) {
switch (policy) {
case CachePolicy.never:
return 'Every visit to a folder fetches it fresh';
return 'Every visit to a folder fetches it fresh; synced files are '
'checked each time you open the app';
case CachePolicy.interval:
return 'Reuse a folder\'s listing until it\'s a few minutes old';
return 'Reuse a folder\'s listing until it\'s a few minutes old; '
'synced files update in the background on the same schedule';
case CachePolicy.manual:
return 'Reuse a folder\'s listing until you pull to refresh';
return 'Reuse a folder\'s listing until you pull to refresh; synced '
'files only update when you pull down or tap Sync now';
}
}
@@ -1354,7 +1362,7 @@ class _CacheSettingsCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final files = context.watch<FilesController>();
return Card(
child: Padding(
@@ -1362,9 +1370,9 @@ class _CacheSettingsCard extends StatelessWidget {
child: Column(
children: [
RadioGroup<CachePolicy>(
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 +1385,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 +1400,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,14 +1408,22 @@ 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()),
),
if (files.cacheIntervalMinutes < 15)
Text(
'While the app is closed, synced files refresh at most '
'every 15 min (an Android limit).',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
@@ -1423,7 +1439,7 @@ class _TabSettingsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
return Card(
clipBehavior: Clip.antiAlias,
@@ -1431,20 +1447,20 @@ class _TabSettingsCard extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorderItem: (oldIndex, newIndex) {
final order = List<AppTab>.from(provider.tabOrder);
final order = List<AppTab>.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 +1470,7 @@ class _TabSettingsCard extends StatelessWidget {
);
}
},
onSetDefault: () => provider.setDefaultTab(tab),
onSetDefault: () => settings.setDefaultTab(tab),
),
],
),
+4 -5
View File
@@ -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<ServerProvider>();
final activities = provider.activities;
final files = context.watch<FilesController>();
final activities = files.activities;
final List<Widget> 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,
);
+63 -80
View File
@@ -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<FavoritesView>
/// back on this tab either way.
void _openFavorite(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
if (item.isFolder) {
provider.navigateToAbsoluteFolder(item.path);
provider.requestTab(AppTab.files);
context.read<FilesController>().navigateToAbsoluteFolder(item.path);
context.read<SettingsController>().requestTab(AppTab.files);
} else {
Navigator.push(
context,
@@ -145,42 +148,39 @@ class _FavoritesViewState extends State<FavoritesView>
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items - same set Files/Photos offer.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
List<NextcloudItem> selected,
) {
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> 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<FavoritesView>
];
}
Future<void> _unfavoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
) async {
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
final ops = context.read<ItemOperations>();
for (final item in items) {
await provider.toggleItemFavorite(item);
await ops.toggleItemFavorite(item);
}
_clearSelection();
}
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
@@ -216,7 +214,7 @@ class _FavoritesViewState extends State<FavoritesView>
final lines = <String>[];
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<FavoritesView>
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final session = context.read<SessionController>();
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<FavoritesView>
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
@@ -313,10 +310,11 @@ class _FavoritesViewState extends State<FavoritesView>
);
if (confirmed != true || !context.mounted) return;
final ops = context.read<ItemOperations>();
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<FavoritesView>
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
@@ -351,7 +348,7 @@ class _FavoritesViewState extends State<FavoritesView>
);
}
Widget _buildControlsRow(ServerProvider provider) {
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
@@ -360,28 +357,28 @@ class _FavoritesViewState extends State<FavoritesView>
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<FavoritesView>
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<FavoritesView>
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<FavoritesView>
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
return Padding(
@@ -447,7 +444,7 @@ class _FavoritesViewState extends State<FavoritesView>
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<FavoritesView>
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<FavoritesView>
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
final isMedia =
(item.type == NextcloudItemType.image ||
@@ -523,7 +520,7 @@ class _FavoritesViewState extends State<FavoritesView>
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<FavoritesView>
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<FavoritesView>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final favoritesController = context.watch<FavoritesController>();
final filesController = context.watch<FilesController>();
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<FavoritesView>
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<FavoritesView>
),
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<FavoritesView>
),
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<FavoritesView>
),
),
)
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<FavoritesView>
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<FavoritesView>
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<FavoritesView>
},
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<FavoritesView>
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
List<NextcloudItem> selectedItems,
) {
return Padding(
@@ -783,10 +769,7 @@ class _FavoritesViewState extends State<FavoritesView>
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,
+269 -94
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
@@ -9,7 +10,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';
@@ -51,10 +54,30 @@ class FileViewerScreen extends StatefulWidget {
/// The other items in the same folder/album, so images and videos can be
/// swiped through without leaving the viewer. Non-media items in this list
/// are ignored. If omitted (or the item isn't an image/video), the viewer
/// just shows [item] on its own.
/// just shows [item] on its own. Works the same way whether or not
/// [localPathResolver] is set - swiping through offline media resolves
/// each sibling's local path on demand, exactly like Files/Photos.
final List<NextcloudItem>? siblings;
const FileViewerScreen({super.key, required this.item, this.siblings});
/// When set, every preview (and "open externally") resolves its file
/// from local disk through this instead of fetching from the server -
/// used by the Offline tab, whose whole point is browsing already-
/// downloaded files with no connection. Called once per item actually
/// shown (the initial item and each swiped-to sibling), not just up
/// front. Actions that need a live server (favorite/delete/download-to-
/// device) hide themselves in this mode; Share/Open-externally/Details
/// still work (Share already prefers the local copy when one exists -
/// see `ShareSheet._shareFileDirectly` - and Details' shares/activity/
/// versions tabs degrade the same way any other offline server call
/// does).
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const FileViewerScreen({
super.key,
required this.item,
this.siblings,
this.localPathResolver,
});
/// Opens the viewer with no page transition — media should appear
/// instantly, not fade/zoom in the way MaterialPageRoute normally would.
@@ -63,10 +86,14 @@ class FileViewerScreen extends StatefulWidget {
static Route<void> route({
required NextcloudItem item,
List<NextcloudItem>? siblings,
Future<String?> Function(NextcloudItem item)? localPathResolver,
}) {
return _InstantOpenPageRoute<void>(
pageBuilder: (context) =>
FileViewerScreen(item: item, siblings: siblings),
pageBuilder: (context) => FileViewerScreen(
item: item,
siblings: siblings,
localPathResolver: localPathResolver,
),
);
}
@@ -110,6 +137,8 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
bool get _isOffline => widget.localPathResolver != null;
bool get _isPdf => _currentItem.name.toLowerCase().endsWith('.pdf');
bool get _isText => _textPreviewExtensions.contains(
p.extension(_currentItem.name).toLowerCase(),
@@ -148,13 +177,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
}
Future<String> _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 +196,23 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return savePath;
}
Future<void> _openExternally(ServerProvider provider) async {
Future<void> _openExternally(SessionController session) async {
final item = _currentItem;
setState(() => _isBusy = true);
try {
final path = await _downloadToTemp(provider, item);
final path = _isOffline
? await widget.localPathResolver!(item)
: await _downloadToTemp(session, item);
if (path == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('This file is no longer available offline'),
),
);
}
return;
}
final result = await OpenFile.open(path);
if (mounted && result.type != ResultType.done) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -194,10 +235,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
/// `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<void> _downloadToDevice(ServerProvider provider) async {
Future<void> _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 +257,16 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
}
}
Future<void> _toggleFavorite(ServerProvider provider) async {
Future<void> _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<void> _deleteCurrentItem(ServerProvider provider) async {
Future<void> _deleteCurrentItem(ItemOperations ops) async {
final item = _currentItem;
final confirmed = await showDialog<bool>(
context: context,
@@ -254,7 +295,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
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 +324,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
@override
Widget build(BuildContext context) {
final provider = context.read<ServerProvider>();
final session = context.read<SessionController>();
final settings = context.read<SettingsController>();
final ops = context.read<ItemOperations>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
@@ -296,7 +339,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
Positioned.fill(
child: Container(
color: colorScheme.surface,
child: _buildBody(context, provider),
child: _buildBody(context, session),
),
),
if (_downloadProgress != null)
@@ -322,8 +365,8 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
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 +410,17 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
child: _MediaActionBar(
isFavorite: _currentItem.isFavorite,
isBusy: _isBusy,
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
// Favorite/delete/download-to-device all need a live
// server - hidden rather than shown-and-failing while
// browsing an already-local file from the Offline tab.
showServerActions: !_isOffline,
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 +432,11 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
);
}
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<SettingsController>();
return PageView.builder(
controller: _pageController,
@@ -396,27 +444,65 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
onPageChanged: (index) => setState(() => _currentIndex = index),
itemBuilder: (context, index) {
final mediaItem = _mediaItems[index];
if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview(
if (!_isOffline) {
if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview(
key: ValueKey(mediaItem.id),
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: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
);
}
return _VideoPreview(
// Offline: each swiped-to item resolves its own local path on
// demand (see FileViewerScreen.localPathResolver's doc comment) -
// this is what makes swiping through offline media behave exactly
// like Files/Photos instead of only ever showing the one item the
// viewer was opened on.
return FutureBuilder<String?>(
key: ValueKey(mediaItem.id),
url: provider.service!.fileUrl(mediaItem.path),
headers: provider.service!.authHeaders,
isActive: index == _currentIndex,
controlsBottomOffset: _controlsVisible ? 108 : 24,
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
future: widget.localPathResolver!(mediaItem),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final localPath = snapshot.data;
if (localPath == null) {
return Center(
child: Icon(
Icons.cloud_off_rounded,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 64,
),
);
}
if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview(url: null, headers: const {}, localPath: localPath);
}
return _VideoPreview(
url: null,
headers: const {},
localPath: localPath,
isActive: index == _currentIndex,
controlsBottomOffset: _controlsVisible ? 108 : 24,
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 +510,23 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return const SizedBox.shrink();
default:
if (_isPdf) {
return _PdfPreview(item: widget.item, provider: provider);
return _PdfPreview(
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
);
}
if (_isText) {
return _TextPreview(item: widget.item, provider: provider);
return _TextPreview(
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
);
}
return _UnsupportedPreview(
item: widget.item,
isBusy: _isBusy,
onOpenExternally: () => _openExternally(provider),
onOpenExternally: () => _openExternally(session),
onOpenDetails: () => DetailsSheet.show(context, widget.item),
);
}
@@ -444,6 +538,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
class _MediaActionBar extends StatelessWidget {
final bool isFavorite;
final bool isBusy;
final bool showServerActions;
final double opacity;
final double blurSigma;
final VoidCallback onShare;
@@ -456,6 +551,7 @@ class _MediaActionBar extends StatelessWidget {
const _MediaActionBar({
required this.isFavorite,
required this.isBusy,
required this.showServerActions,
required this.opacity,
required this.blurSigma,
required this.onShare,
@@ -488,30 +584,33 @@ class _MediaActionBar extends StatelessWidget {
tooltip: 'Share',
onTap: isBusy ? null : onShare,
),
_ActionIconButton(
icon: isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
tooltip: 'Favorite',
color: isFavorite ? Colors.red : null,
onTap: onFavorite,
),
if (showServerActions)
_ActionIconButton(
icon: isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
tooltip: 'Favorite',
color: isFavorite ? Colors.red : null,
onTap: onFavorite,
),
_ActionIconButton(
icon: Icons.open_in_new_rounded,
tooltip: 'Open externally',
onTap: isBusy ? null : onOpenExternally,
),
_ActionIconButton(
icon: Icons.download_rounded,
tooltip: 'Download',
onTap: isBusy ? null : onDownload,
),
_ActionIconButton(
icon: Icons.delete_outline_rounded,
tooltip: 'Delete',
color: colorScheme.error,
onTap: isBusy ? null : onDelete,
),
if (showServerActions)
_ActionIconButton(
icon: Icons.download_rounded,
tooltip: 'Download',
onTap: isBusy ? null : onDownload,
),
if (showServerActions)
_ActionIconButton(
icon: Icons.delete_outline_rounded,
tooltip: 'Delete',
color: colorScheme.error,
onTap: isBusy ? null : onDelete,
),
_ActionIconButton(
icon: Icons.info_outline_rounded,
tooltip: 'Details',
@@ -562,10 +661,20 @@ class _ActionIconButton extends StatelessWidget {
}
class _ImagePreview extends StatefulWidget {
final String url;
final String? url;
final Map<String, String> headers;
const _ImagePreview({super.key, required this.url, required this.headers});
/// When set, read the image from this on-disk path instead of [url] -
/// already-resolved by the caller (see
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
const _ImagePreview({
super.key,
required this.url,
required this.headers,
this.localPath,
});
@override
State<_ImagePreview> createState() => _ImagePreviewState();
@@ -641,24 +750,38 @@ class _ImagePreviewState extends State<_ImagePreview>
minScale: 0.8,
maxScale: 5.0,
child: Center(
child: Image.network(
widget.url,
headers: widget.headers,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
),
),
),
child: widget.localPath != null
? Image.file(
File(widget.localPath!),
fit: BoxFit.contain,
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
),
),
)
: Image.network(
widget.url!,
headers: widget.headers,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Center(
child: CircularProgressIndicator(
color: colorScheme.primary,
),
);
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
),
),
),
),
),
),
@@ -667,10 +790,15 @@ class _ImagePreviewState extends State<_ImagePreview>
}
class _VideoPreview extends StatefulWidget {
final String url;
final String? url;
final Map<String, String> headers;
final bool isActive;
/// When set, read the video from this on-disk path instead of [url] -
/// already-resolved by the caller (see
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
/// Distance from the bottom of the screen the transport controls should
/// sit at. Passed in by the parent so the controls can track the floating
/// action bar: resting just above it when visible, sliding down to hug
@@ -686,6 +814,7 @@ class _VideoPreview extends StatefulWidget {
super.key,
required this.url,
required this.headers,
this.localPath,
this.isActive = true,
this.controlsBottomOffset = 24,
this.opacity = 0.55,
@@ -708,10 +837,12 @@ class _VideoPreviewState extends State<_VideoPreview> {
Future<void> _init() async {
try {
final controller = VideoPlayerController.networkUrl(
Uri.parse(widget.url),
httpHeaders: widget.headers,
);
final controller = widget.localPath != null
? VideoPlayerController.file(File(widget.localPath!))
: VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: widget.headers,
);
await controller.initialize();
if (!mounted) {
controller.dispose();
@@ -752,7 +883,7 @@ class _VideoPreviewState extends State<_VideoPreview> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final progressBarStyle = context
.watch<ServerProvider>()
.watch<SettingsController>()
.mediaProgressBarStyle;
if (_error != null) {
return Center(
@@ -966,9 +1097,18 @@ 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});
/// When set, read bytes from the local path it resolves to instead of
/// fetching from the server - see
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const _PdfPreview({
required this.item,
required this.session,
this.localPathResolver,
});
@override
State<_PdfPreview> createState() => _PdfPreviewState();
@@ -986,11 +1126,25 @@ class _PdfPreviewState extends State<_PdfPreview> {
Future<void> _load() async {
try {
final bytes = await widget.provider.service!.fetchBytes(widget.item.path);
final Uint8List bytes;
if (widget.localPathResolver != null) {
final localPath = await widget.localPathResolver!(widget.item);
if (localPath == null) {
if (mounted) {
setState(() => _error = 'No longer available offline');
}
return;
}
bytes = await File(localPath).readAsBytes();
} else {
bytes = Uint8List.fromList(
await widget.session.service!.fetchBytes(widget.item.path),
);
}
if (!mounted) return;
setState(() {
_controller = PdfControllerPinch(
document: PdfDocument.openData(Uint8List.fromList(bytes)),
document: PdfDocument.openData(bytes),
);
});
} catch (e) {
@@ -1033,9 +1187,18 @@ 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});
/// When set, read bytes from the local path it resolves to instead of
/// fetching from the server - see
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const _TextPreview({
required this.item,
required this.session,
this.localPathResolver,
});
@override
State<_TextPreview> createState() => _TextPreviewState();
@@ -1053,7 +1216,19 @@ class _TextPreviewState extends State<_TextPreview> {
Future<void> _load() async {
try {
final bytes = await widget.provider.service!.fetchBytes(widget.item.path);
final List<int> bytes;
if (widget.localPathResolver != null) {
final localPath = await widget.localPathResolver!(widget.item);
if (localPath == null) {
if (mounted) {
setState(() => _error = 'No longer available offline');
}
return;
}
bytes = await File(localPath).readAsBytes();
} else {
bytes = await widget.session.service!.fetchBytes(widget.item.path);
}
if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
}
+294 -337
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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<LockScreenView>
if (state != AppLifecycleState.resumed || !mounted || _authenticating) {
return;
}
if (context.read<ServerProvider>().needsUnlock) _attemptUnlock();
if (context.read<SessionController>().needsUnlock) _attemptUnlock();
}
Future<void> _attemptUnlock() async {
if (_authenticating || !mounted) return;
setState(() => _authenticating = true);
await context.read<ServerProvider>().attemptUnlock();
await context.read<SessionController>().attemptUnlock();
if (mounted) setState(() => _authenticating = false);
}
+23 -23
View File
@@ -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<LoginView> {
void initState() {
super.initState();
if (widget.isAddingAccount) {
_originalActiveAccountId = context.read<ServerProvider>().activeAccountId;
_originalActiveAccountId = context.read<SessionController>().activeAccountId;
}
}
@@ -41,13 +41,13 @@ class _LoginViewState extends State<LoginView> {
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
context.read<ServerProvider>().startLoginFlow(
context.read<SessionController>().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<LoginView> {
/// silently failing every time - a delete button on the row itself (see
/// [_SavedAccountRow]) is the way out.
Future<void> _continueAsAccount(SavedAccount account) async {
final provider = context.read<ServerProvider>();
final success = await provider.switchAccount(account.id);
final session = context.read<SessionController>();
final success = await session.switchAccount(account.id);
if (success || !mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
@@ -98,25 +98,25 @@ class _LoginViewState extends State<LoginView> {
},
);
if (confirmed != true || !mounted) return;
await context.read<ServerProvider>().removeAccount(account.id);
await context.read<SessionController>().removeAccount(account.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
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<LoginView> {
// 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<LoginView> {
// 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<LoginView> {
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<LoginView> {
// 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<SavedAccount> accounts;
final ValueChanged<SavedAccount> onSelect;
+5 -5
View File
@@ -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<LoginWebViewView> {
}
void _cancel() {
context.read<ServerProvider>().cancelLoginFlow();
context.read<SessionController>().cancelLoginFlow();
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
// 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();
+42 -37
View File
@@ -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<MoveCopyDestinationPicker> {
Future<void> _fetch(String path) async {
setState(() => _isLoading = true);
final raw = await context.read<ServerProvider>().fetchFolderListing(path);
final raw = await context.read<FilesController>().fetchFolderListing(
path,
);
if (!mounted) return;
setState(() {
_rawItems = raw;
@@ -109,7 +113,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
return false;
}
Future<void> _confirm(ServerProvider provider) async {
Future<void> _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<MoveCopyDestinationPicker> {
);
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<MoveCopyDestinationPicker> {
).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<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
// 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<SessionController>();
final isFolder = item.isFolder;
return Padding(
@@ -272,7 +277,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
children: [
ItemThumbnail(
item: item,
provider: provider,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
@@ -312,7 +317,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
);
}
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<MoveCopyDestinationPicker> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final files = context.watch<FilesController>();
final ops = context.read<ItemOperations>();
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<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
),
),
)
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<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
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<MoveCopyDestinationPicker> {
child: Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
),
@@ -499,7 +504,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
child: FilledButton.icon(
onPressed: invalidDestination || _isSubmitting
? null
: () => _confirm(provider),
: () => _confirm(ops),
icon: _isSubmitting
? const SizedBox(
width: 18,
+70 -70
View File
@@ -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<PhotosView>
/// 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<PhotosView>
);
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<SelectionAction> _buildSelectionActions(
ServerProvider provider,
PickController pick,
List<NextcloudItem> 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<PhotosView>
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<PhotosView>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final photosController = context.watch<PhotosController>();
final filesController = context.watch<FilesController>();
final pick = context.watch<PickController>();
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<PhotosView>
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<PhotosView>
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<PhotosView>
),
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<PhotosView>
),
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<PhotosView>
),
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<PhotosView>
),
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<PhotosView>
},
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<PhotosView>
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
PickController pick,
List<NextcloudItem> selectedItems,
) {
return Padding(
@@ -445,7 +453,7 @@ class _PhotosViewState extends State<PhotosView>
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
pick,
selectedItems,
))
IconButton(
@@ -463,12 +471,10 @@ class _PhotosViewState extends State<PhotosView>
);
}
Widget _buildPhotoTile(
BuildContext context,
NextcloudItem photo,
ServerProvider provider,
) {
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme;
final pick = context.watch<PickController>();
final session = context.watch<SessionController>();
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<PhotosView>
// 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<PhotosView>
.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<PhotosView>
);
}
void _openLightbox(
BuildContext context,
NextcloudItem photo,
ServerProvider provider,
) {
void _openLightbox(BuildContext context, NextcloudItem photo) {
final photos = context.read<PhotosController>().items;
Navigator.push(
context,
FileViewerScreen.route(item: photo, siblings: provider.photoItems),
FileViewerScreen.route(item: photo, siblings: photos),
);
}
Future<void> _favoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
) async {
Future<void> _favoriteSelected(List<NextcloudItem> items) async {
final ops = context.read<ItemOperations>();
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<PhotosView>
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
@@ -610,7 +611,7 @@ class _PhotosViewState extends State<PhotosView>
final lines = <String>[];
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<PhotosView>
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final session = context.read<SessionController>();
_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<PhotosView>
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
@@ -704,10 +704,11 @@ class _PhotosViewState extends State<PhotosView>
);
if (confirmed != true || !context.mounted) return;
final ops = context.read<ItemOperations>();
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<PhotosView>
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
+16 -26
View File
@@ -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<RecentView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final recent = context.watch<RecentController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchRecent(),
);
WidgetsBinding.instance.addPostFrameCallback((_) => recent.fetchAll());
}
final items = provider.recentItems;
final items = recent.items;
final List<Widget> 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<RecentView> {
),
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<RecentView> {
),
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<RecentView> {
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<RecentView> {
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<RecentView> {
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<RecentView> {
);
}
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<RecentController>();
final siblings = recent.items.where((i) => i.isMedia).toList();
Navigator.push(
context,
FileViewerScreen.route(item: item, siblings: siblings),
+8 -6
View File
@@ -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<SearchView> {
}
Future<void> _runSearch(String query) async {
final provider = context.read<ServerProvider>();
final files = context.read<FilesController>();
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<SearchView> {
/// browsing before), switches the shell to the Files tab, and closes
/// search. For a file, also opens it once back on Files.
Future<void> _openResult(NextcloudItem item) async {
final provider = context.read<ServerProvider>();
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
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) {
+53 -56
View File
@@ -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<ShareUploadView> {
// 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<ServerProvider>().navigateToAbsoluteFolder('/');
if (mounted) context.read<FilesController>().navigateToAbsoluteFolder('/');
});
}
@@ -53,11 +55,13 @@ class _ShareUploadViewState extends State<ShareUploadView> {
super.dispose();
}
Future<void> _uploadHere(ServerProvider provider) async {
Future<void> _uploadHere(SessionController session) async {
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
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<ShareUploadView> {
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<ShareUploadView> {
);
}
Widget _buildControlsRow(ServerProvider provider) {
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
@@ -91,28 +95,28 @@ class _ShareUploadViewState extends State<ShareUploadView> {
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<ShareUploadView> {
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<ShareUploadView> {
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<ShareUploadView> {
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<ShareUploadView> {
// 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<FilesController>();
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
@@ -203,9 +205,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
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<ShareUploadView> {
children: [
ItemThumbnail(
item: item,
provider: provider,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
@@ -255,13 +255,10 @@ class _ShareUploadViewState extends State<ShareUploadView> {
);
}
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<FilesController>();
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
@@ -272,7 +269,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
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<ShareUploadView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final hasBreadcrumbs = provider.pathStack.length > 1;
final currentPath = provider.pathStack.last;
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
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<ShareUploadView> {
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<ShareUploadView> {
child: controlsColumn,
),
),
if (provider.isLoading)
if (files.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
@@ -380,7 +378,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
),
),
)
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<ShareUploadView> {
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<ShareUploadView> {
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<ShareUploadView> {
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<ShareUploadView> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _uploadHere(provider),
onPressed: () => _uploadHere(session),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
),
+19 -22
View File
@@ -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<SharesView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final sharesController = context.watch<SharesController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchShares(),
(_) => sharesController.fetchAll(),
);
}
final shares = provider.shares;
final shares = sharesController.shares;
final List<Widget> contentSlivers = [
SliverToBoxAdapter(
@@ -80,19 +80,20 @@ class _SharesViewState extends State<SharesView> {
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<SharesView> {
),
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<SharesView> {
),
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<SharesView> {
),
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<SharesView> {
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<SharesView> {
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<SharesView> {
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<SharesView> {
Future<void> _confirmUnshare(
BuildContext context,
ServerProvider provider,
NextcloudShare share,
) async {
final confirmed = await showDialog<bool>(
@@ -303,7 +298,9 @@ class _SharesViewState extends State<SharesView> {
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share);
final success = await context.read<SharesController>().deleteShare(
share,
);
messenger.showSnackBar(
SnackBar(
content: Text(
+16 -26
View File
@@ -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<TrashView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final trashController = context.watch<TrashController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchTrash(),
(_) => trashController.fetchAll(),
);
}
final trash = provider.trashItems;
final trash = trashController.items;
final List<Widget> 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<TrashView> {
),
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<TrashView> {
),
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<TrashView> {
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<TrashView> {
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<TrashView> {
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<TrashView> {
color: colorScheme.error,
),
tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, provider, item),
onPressed: () => _confirmDeleteForever(context, item),
),
],
),
@@ -227,13 +222,9 @@ class _TrashViewState extends State<TrashView> {
);
}
Future<void> _restore(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
Future<void> _restore(BuildContext context, NextcloudItem item) async {
final messenger = ScaffoldMessenger.of(context);
final success = await provider.restoreTrashItem(item);
final success = await context.read<TrashController>().restore(item);
messenger.showSnackBar(
SnackBar(
content: Text(
@@ -246,7 +237,6 @@ class _TrashViewState extends State<TrashView> {
Future<void> _confirmDeleteForever(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
final confirmed = await showDialog<bool>(
@@ -276,7 +266,7 @@ class _TrashViewState extends State<TrashView> {
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteTrashItemForever(item);
final success = await context.read<TrashController>().deleteForever(item);
messenger.showSnackBar(
SnackBar(
content: Text(
+12 -1
View File
@@ -14,7 +14,12 @@ import '../views/trash_view.dart';
Widget buildAppTabView(AppTab tab, ScrollController controller) {
switch (tab) {
case AppTab.files:
return FilesView(scrollController: controller);
// Keyed so the Files and Offline tabs (same widget type) never share
// State when the visible-tab list shifts positions in the IndexedStack.
return FilesView(
key: const ValueKey('files'),
scrollController: controller,
);
case AppTab.photos:
return PhotosView(scrollController: controller);
case AppTab.favorites:
@@ -27,5 +32,11 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
return SharesView(scrollController: controller);
case AppTab.recent:
return RecentView(scrollController: controller);
case AppTab.offline:
return FilesView(
key: const ValueKey('offline'),
scrollController: controller,
offline: true,
);
}
}
@@ -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<DetailsActivityTab> {
List<NextcloudActivity> _activities = [];
Future<void> _load() async {
final provider = context.read<ServerProvider>();
final result = await provider.fetchFileActivity(widget.item);
final ops = context.read<ItemOperations>();
final result = await ops.fetchFileActivity(widget.item);
if (!mounted) return;
setState(() {
_activities = result;
@@ -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<DetailsVersionsTab> {
List<NextcloudFileVersion> _versions = [];
Future<void> _load() async {
final provider = context.read<ServerProvider>();
final result = await provider.fetchFileVersions(widget.item);
final ops = context.read<ItemOperations>();
final result = await ops.fetchFileVersions(widget.item);
if (!mounted) return;
setState(() {
_versions = result;
@@ -37,9 +37,9 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
}
Future<void> _restore(NextcloudFileVersion version) async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
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<DetailsVersionsTab> {
}
Future<void> _download(NextcloudFileVersion version) async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
try {
final tempDir = await getTemporaryDirectory();
@@ -64,7 +64,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
tempDir.path,
'${version.versionLabel}_${widget.item.name}',
);
final success = await provider.downloadVersion(
final success = await ops.downloadVersion(
widget.item,
version.versionLabel,
tempPath,
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../providers/files_controller.dart';
import 'segmented_icon_toggle.dart';
import 'sort_menu_button.dart';
/// The Files tab's sort/hidden/scope/type-filter/view-mode controls row,
/// shared with the Offline tab so both read and write the exact same
/// [FilesController] prefs. [folderPath] picks which folder's sort applies
/// (the Files tab's current folder, or the folder being browsed offline).
/// [showStorageScope] is off for Offline - a local mirror has no
/// cloud/external distinction.
class FilesControlsRow extends StatelessWidget {
final String folderPath;
final bool showStorageScope;
final ScrollController? scrollController;
const FilesControlsRow({
super.key,
required this.folderPath,
this.showStorageScope = true,
this.scrollController,
});
@override
Widget build(BuildContext context) {
final files = context.watch<FilesController>();
return SizedBox(
height: 44,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.sortAscendingFor(folderPath)
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.sortAscendingFor(folderPath)
? 'Ascending'
: 'Descending',
onPressed: () => files.toggleSortOrderFor(folderPath),
),
// A plain width, not Expanded - this row scrolls horizontally,
// which gives every control unbounded width to lay out in, so a
// flex child would throw.
SizedBox(
width: 130,
child: SortMenuButton(
field: files.sortFieldFor(folderPath),
onChanged: (field) => files.setSortFieldFor(folderPath, field),
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
if (showStorageScope) ...[
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
],
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
);
}
}
+23 -9
View File
@@ -1,6 +1,7 @@
import 'dart:io';
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,15 +52,21 @@ 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;
/// The on-device copy of [item] (the Offline tab's synced mirror) - when
/// set, images render from this file instead of a server preview, so
/// thumbnails work with no connection.
final File? localFile;
const ItemThumbnail({
super.key,
required this.item,
required this.provider,
required this.service,
this.localFile,
required this.size,
required this.borderRadius,
required this.iconSize,
@@ -71,6 +78,7 @@ class ItemThumbnail extends StatelessWidget {
final isMedia =
item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video;
final useLocal = item.type == NextcloudItemType.image && localFile != null;
// Decode straight to the size this thumbnail is actually painted at —
// the server hands back a 500x500 preview regardless, and decoding that
// in full for a ~40dp tile (times however many are on screen while
@@ -86,16 +94,22 @@ class ItemThumbnail extends StatelessWidget {
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(borderRadius),
),
child: isMedia && item.previewUrl != null
child: useLocal || (isMedia && item.previewUrl != null)
? Stack(
fit: StackFit.expand,
children: [
Image.network(
item.previewUrl!,
headers: provider.service?.authHeaders,
Image(
image: ResizeImage(
useLocal
? FileImage(localFile!) as ImageProvider
: NetworkImage(
item.previewUrl!,
headers: service?.authHeaders,
),
width: cachePixels,
height: cachePixels,
),
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
loadingBuilder: (context, child, progress) {
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/sync_status_controller.dart';
/// The configured sync targets (folders/files) with a "stop syncing"
/// action per target - what used to be Settings' Device Sync card's own
/// inline list, moved into a sheet here so the main Offline view can look
/// like a plain Files-style browser instead of always showing this
/// management UI up top.
class ManageSyncedFoldersSheet extends StatelessWidget {
final SyncStatusController sync;
const ManageSyncedFoldersSheet({super.key, required this.sync});
static void show(BuildContext context) {
final sync = context.read<SyncStatusController>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => ManageSyncedFoldersSheet(sync: sync),
);
}
void _remove(BuildContext context, String path) {
sync.removeSyncedPath(path);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Removed from device sync'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: sync,
builder: (context, _) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final everything = sync.syncEverything;
final targets = sync.syncedPaths;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Synced to this device',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (everything)
Text(
'Every folder in this account is being synced to this '
'device.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else if (targets.isEmpty)
Text(
'Nothing synced yet - select a folder or file in Files '
'and use "Sync to device" to mirror it here for offline '
'access.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: targets.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final target = targets[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.sync_rounded),
title: Text(target),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing',
onPressed: () => _remove(context, target),
),
);
},
),
),
],
),
),
);
},
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import 'synced_header_scaffold.dart' show formatBytes;
/// Grid content for an image/video: the actual preview fills the whole card
/// as a background, with the name/size legible over a bottom scrim -
/// matching a Google Photos-style grid instead of a small icon badge.
/// Shared by the Files and Offline tabs; they differ only in where the
/// pixels come from ([imageBuilder] - a server preview vs. the local
/// mirror file) and what to show if that fails ([fallbackBuilder]).
class MediaGridTile extends StatelessWidget {
final NextcloudItem item;
/// Given the pixel width to decode at, returns the image to paint.
/// Decoding straight to the painted size (rather than the full 500x500
/// server preview / full-resolution local file) avoids a real source of
/// scrolling jank.
final ImageProvider Function(int cachePixels) imageBuilder;
final WidgetBuilder fallbackBuilder;
const MediaGridTile({
super.key,
required this.item,
required this.imageBuilder,
required this.fallbackBuilder,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final cachePixels =
(constraints.maxWidth * MediaQuery.of(context).devicePixelRatio)
.round();
return Stack(
fit: StackFit.expand,
children: [
Image(
image: imageBuilder(cachePixels),
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => fallbackBuilder(ctx),
),
if (item.type == NextcloudItemType.video)
const Center(
child: Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 36,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 20, 10, 8),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
Text(
formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 10,
),
),
],
),
),
),
],
);
},
);
}
}

Some files were not shown because too many files have changed in this diff Show More