7 Commits
Author SHA1 Message Date
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
ayushyaandClaude Sonnet 5 faaec5365f Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s
- New native sync engine (SyncEngine/SyncWorker/ConflictResolveWorker,
  WorkManager-based) that mirrors selected Nextcloud folders or files to
  app-private local storage, periodically and on-demand ("Sync now"),
  with never-auto-resolved conflict notifications.
- Single files (not just folders) can now be marked "Sync to device".
- Live sync status (syncing/synced/conflicts) pushes from native to Dart
  over a new EventChannel; the persistent header chip/panel now reflects
  device-sync status instead of the WebDAV-refresh loading state, with a
  cloud_off/cloud_sync/cloud_done/cloud_alert icon set and an expandable
  conflicts list with in-app "Keep local"/"Use server" resolution.
  Per-item cloud_done/sync badges show on Files tiles.
- Share/Download short-circuit to the local copy for already-synced
  files instead of a fresh network fetch.
- Settings gained a Device Sync section (per-folder list, "Sync
  everything", Wi-Fi-only toggle, manual "Sync now").
- Fixed two release-only bugs found via on-device testing: Android's
  HttpURLConnection silently rejects the PROPFIND method (switched to
  OkHttp), and R8 was stripping WorkManager's reflection-instantiated
  internals (broadened proguard-rules.pro to keep androidx.work.**
  wholesale rather than chasing individual classes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 00:52:56 -04:00
ayushyaandClaude Sonnet 5 722b66f7b1 Fix release signing and unify "+" upload with the share-intent path
Build APK / build (push) Successful in 5m21s
- Release builds now sign with a dedicated keystore (local
  android/key.properties or CI secrets, both gitignored) instead of each
  machine's own debug key, so a downloaded release APK can actually
  update a previous install instead of failing with "App not installed"
  (mismatched signature).
- The Files tab's "+" -> "Upload File" now goes through the same
  ShareUploadView destination picker and background foreground-service
  upload as receiving a file via Android's "Share to..." sheet, instead
  of a separate blocking in-app-only upload path. Removed the
  now-unused ServerProvider/NextcloudService.uploadFileFromPath.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:17:46 -04:00
ayushyaandClaude Sonnet 5 dd0c27035a Add launch-time notification permission prompt and UI polish
Build APK / build (push) Successful in 6m20s
- Request POST_NOTIFICATIONS once on first app launch, with a
  plain-language rationale dialog shown before the OS prompt
  (permission_handler), since upload/download progress relies on it.
- ShareUploadView no longer shows "More tabs" - there's nowhere useful
  for it to go mid-upload.
- FloatingBottomNavBar's pill scrolls horizontally instead of
  overflowing when the visible tab set doesn't fit.
- Settings account card: drop the separate server-address row and its
  globe icon, showing the host inline with the quota percentage/
  "Unlimited storage" text instead (left-aligned, same size).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:52:16 -04:00
ayushyaandClaude Sonnet 5 b1400e399f Add in-app copy/move, background downloads, and a Favorites tab
Build APK / build (push) Successful in 5m18s
- WebDAV MOVE/COPY-backed copy/move for files and folders, with a
  destination picker (independent nav state so it doesn't disturb the
  Files tab's browsing position) and a conflict-resolution sheet
  (overwrite all / keep both / decide per item).
- Downloads now use the same foreground-service + notification approach
  as uploads, surviving app closure.
- Favorites is now its own tab (account-wide, via a WebDAV SEARCH query)
  instead of a Files-tab filter toggle, which couldn't represent
  favorited items outside the currently browsed folder correctly.
- Share sheet restyled to match the app's design language, plus a
  "share file directly" action via the OS share sheet; details sheet
  tab icons resized to match the bottom nav.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:20:56 -04:00
ayushya 44a23c3632 Add GET_CONTENT picker support, fix account/login bugs, restyle selection UI
Build APK / build (push) Successful in 7m40s
- Let Noo be picked as a source (Files + Photos) by other apps' native
  file/photo pickers via Android's GET_CONTENT intent, mirroring the
  existing "Share to Noo" plumbing: MainActivity.kt/PickIntentService/
  PickRequest, ServerProvider.pickRequest/confirmPick/cancelPick, and
  pick-mode tap handling in FilesView/PhotosView. While picking, the
  bottom nav and MoreTabsButton are restricted to Files/Photos only.
- Fix two account bugs: testConnection() failures no longer delete a
  still-valid stored app password (only an actual 401 does), and
  switchAccount() now retries a nominally-active-but-logged-out account
  instead of no-op'ing - both were silently locking users out of "Continue
  as" with no way back in except removing and re-adding the account
  (now exposed via login_view.dart's inline remove button).
- Fix the login WebView: clear cookies before every load so "Add Account"
  can't silently reuse an existing session, and push it as a
  non-animated route to avoid a known WebView-as-PlatformView black-screen-
  on-pop issue under Impeller.
- Redesign the Files/Photos selection toolbar to take over the top app bar
  entirely (SyncedHeaderScaffold.selectionBar) instead of sitting as a
  second bar under the controls row, with a horizontally-scrollable,
  uniformly-colored actions row and the same one-shot scroll-hint nudge
  the controls row uses.
- Redesign ShareUploadView to share the same SyncedHeaderScaffold chrome
  as every tab, with the uploading-file summary (marqueed via the new
  shared MarqueeTitle widget) and upload button grouped into one
  rounded/elevated bottom sheet.
- Restyle ShareSheet to open on the same DetailsHeader every other
  per-item sheet uses, and fix a couple of small design-language drifts
  (hardcoded text style, a non-rounded icon).
- Settings' account card: icon buttons with tooltips instead of text
  buttons for refresh/logout/remove, divider removed.
2026-09-17 23:36:54 -04:00
82 changed files with 9792 additions and 3322 deletions
+168 -70
View File
@@ -8,7 +8,9 @@ lib/
# MainShellView (bottom-nav shell), share-intent listener # MainShellView (bottom-nav shell), share-intent listener
models/ # plain data classes (NextcloudItem, NextcloudShare, models/ # plain data classes (NextcloudItem, NextcloudShare,
# SavedAccount, AppTab, ...) # 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, services/ # network/IO: NextcloudService, LoginFlowService,
# AccountStore, AppLockService # AccountStore, AppLockService
theme/ # AppTheme (Material 3 ThemeData) theme/ # AppTheme (Material 3 ThemeData)
@@ -23,89 +25,122 @@ lib/
`views/` files are screens routed to directly (a tab, or pushed via `views/` files are screens routed to directly (a tab, or pushed via
`Navigator`). `widgets/` files are building blocks used by more than one `Navigator`). `widgets/` files are building blocks used by more than one
view (or complex enough to warrant their own file) — nothing in `widgets/` 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 owns app state itself; it reads whichever controller(s) it needs via
read via `context.watch`/`context.read`. `context.watch`/`context.read`.
## State management ## State management
There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart) State used to live in one 2300+ line `ServerProvider` god object. It's now
(~1500 lines). It is created once in `main()` and provided at the root with split into ten focused `ChangeNotifier`s plus one plain coordinator, all
`provider`'s `ChangeNotifierProvider`. It owns: 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 - [`SessionController`](../../lib/providers/session_controller.dart) — the
is active (`activeAccountId`/`activeAccount`), and a `_sessionGeneration` foundation everything else depends on. Owns multi-account state (the
counter incremented on every account switch so an in-flight fetch from the saved-accounts list, which one is active, `sessionGeneration` — see
account just left can recognize it's stale and discard its result instead below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
of writing into the newly-active account's state — every method that `loginFlowStatus`), the active `NextcloudService` instance, and login lock
writes fetched data into a shared field (`refreshData`, `fetchAllMedia`, (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
`fetchTrash`, `fetchShares`, `fetchRecent`, `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
`_applyCredentialsForAccount`) captures the generation at entry and checks `addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
it before each write. See `server.md` for the full account-switch/storage controllers — constructed after `SessionController` and unable to hold a
story. forward reference to it — can react to login/logout/account-switch
- auth/session state for whichever account is active (`isLoggedIn`, without a circular dependency.
`isRestoringSession`, login-flow status) - [`SettingsController`](../../lib/providers/settings_controller.dart) —
- the active `NextcloudService` instance (null until logged in) global UI prefs independent of login state: theme mode/seed color/dynamic
- all fetched data, scoped to the active account (`items`, `quota`, color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab
`activities`, `photoItems`, `trashItems`, `shares`, `recentItems`) — all order/visibility/default (`requestedTab`/`requestTab`/
torn down and refetched fresh on every account switch (no simultaneous `consumeRequestedTab`), swipe actions.
multi-account state; only one account's content is ever live in memory) - [`FilesController`](../../lib/providers/files_controller.dart) — the
- navigation-within-files state (`currentFolderPath`, `pathStack`), plus an Files tab: `items`/`currentFolderPath`/`pathStack`, the in-memory
in-memory `_directoryCache` keyed by folder path (see `CachePolicy`) `_directoryCache` (see `CachePolicy`), per-account display prefs
- UI settings that persist across launches — split into **global** (theme (grid/list, hidden files, storage scope, sort field/order, per-folder
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur, sort map), and the shared `applyCommonFilters`/`applyFilesDisplayPrefs`
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe helpers Photos/Favorites reuse rather than duplicating the same
actions, login lock — see below) and **per-account** (grid/list view, filter/sort logic.
favorites-only, show-hidden, storage scope, Photos sort, Files' - [`PhotosController`](../../lib/providers/photos_controller.dart) /
per-folder sort map, cache policy) — see `server.md` for exactly which is [`FavoritesController`](../../lib/providers/favorites_controller.dart) —
which and why each depends on `FilesController` for the shared storage-scope toggle and
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/ display-prefs helpers, but owns its own account-wide item list and
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via independent favorites-only/hidden/sort state.
`AppLockService` (a thin wrapper over `local_auth` — this app never - [`TrashController`](../../lib/providers/trash_controller.dart) /
stores or hashes a PIN itself, it delegates entirely to whatever [`SharesController`](../../lib/providers/shares_controller.dart) /
credential the OS already has configured). `needsUnlock` is [`RecentController`](../../lib/providers/recent_controller.dart) — one
`loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient per remaining tab, each just `items`/`isLoading`/`errorMessage` plus that
(never persisted) and reset to `false` on every backgrounding tab's own fetch/mutate methods. All three (plus Photos/Favorites) share
(`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has one shape: capture `session.sessionGeneration` at fetch entry, set
real value rather than only firing once per cold start. `switchAccount`/ loading/clear error, fetch, check the generation before writing the
`cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not result, `notifyListeners()`.
disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared - [`SyncStatusController`](../../lib/providers/sync_status_controller.dart)
`_passGate` helper, which no-ops unless both `loginLockEnabled` and the — device-sync settings and live status (`syncStatusFor(item)`,
relevant per-feature toggle are on. `syncHeaderStatus`, conflicts), subscribed to `SyncService`'s status
stream directly rather than living inside the same object as the item
lists it decorates.
- [`PickController`](../../lib/providers/pick_controller.dart) — "being
picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/
`itemMatchesPickFilter`) — see `server.md`.
- [`ItemOperations`](../../lib/providers/item_operations.dart) — not a
`ChangeNotifier`; a plain, `const`-constructible class holding direct
references to `SessionController`/`FilesController`/`PhotosController`/
`FavoritesController`. The one place that knows how a mutation
(delete/rename/move/copy/favorite-toggle/create-folder) ripples across
those three tabs' independent item lists — deliberately direct
references rather than a generic event bus, so a caller can `await` a
mutation and know every affected list has already been reconciled by the
time it returns, the same guarantee the old god object gave for free.
Also home to the file-details sheet's per-item pass-throughs
(shares/activity/versions/restore) — stateless, so they don't need a
controller of their own.
New **global** state belongs on `ServerProvider` as a private field + getter `sessionGeneration` (on `SessionController`, read by every other
+ a method that mutates it, calls `notifyListeners()`, and persists via controller) is incremented on every account switch so an in-flight fetch
`_prefsFuture`. New **per-account** state follows the same shape but from the account just left can recognize it's stale and discard its result
persists through `_persistAccountPref` (namespaces the pref key under 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 `acct_<activeAccountId>_...` via `AccountStore.accountPrefKey`) and must be
reset/reloaded in `_applyAccountPrefs` so it's correct after a switch. reset/reloaded on `SessionController`'s account-activated listener so it's
Screen-local state (e.g. a `TextEditingController`, an expanded/collapsed correct after a switch. Screen-local state (e.g. a `TextEditingController`,
flag) stays in that view's own `State` class — see `standards.md` for the an expanded/collapsed flag) stays in that view's own `State` class — see
split. `standards.md` for the split.
There's no separate repository/data layer: views call `ServerProvider` There's no separate repository/data layer: views call controller methods
methods directly, which call `NextcloudService`/`LoginFlowService`/ directly, which call `NextcloudService`/`LoginFlowService`/`AccountStore`/
`AccountStore`/`AppLockService`. `AppLockService`.
## Navigation / screen flow ## Navigation / screen flow
`main.dart`'s `NextcloudApp` picks the app's `home` screen from provider `main.dart`'s `NextcloudApp` picks the app's `home` screen from
state, no named routes: `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 tinted via `ColorFiltered` to the theme's `onSurface` so it works in both
light/dark, plus a small spinner, while `flutter_secure_storage`/ light/dark, plus a small spinner, while `flutter_secure_storage`/
`shared_preferences` are read on startup) `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 Flow v2) — `isLoggedIn` is only ever false here or after the last saved
account is removed; switching between multiple saved accounts never account is removed; switching between multiple saved accounts never
routes through this screen (see `server.md`) 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` - else `MainShellView`
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files, `MainShellView` is a bottom-nav `IndexedStack` over up to 7 tabs — Files,
Photos, Activity, Trash, Shares, Recent — user-configurable (order, Photos, Favorites, Activity, Trash, Shares, Recent — user-configurable
visibility up to `maxVisibleTabs`, default tab) via `AppTab`/`ServerProvider` (order, visibility up to `maxVisibleTabs`, default tab) via
and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`). `AppTab`/`SettingsController` and rendered through `buildAppTabView`
(`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the
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.
Each tab keeps its own `ScrollController` (survives tab switches via Each tab keeps its own `ScrollController` (survives tab switches via
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab `IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
@@ -113,15 +148,78 @@ owns the app's share-intent listener (`ShareIntentService`, backed by
hand-rolled native handling in `MainActivity.kt` - see `server.md` for why hand-rolled native handling in `MainActivity.kt` - see `server.md` for why
this isn't the `receive_sharing_intent` plugin): both `getInitialShare()` this isn't the `receive_sharing_intent` plugin): both `getInitialShare()`
(cold start via another app's "Share to...") and `onNewShare` (already (cold start via another app's "Share to...") and `onNewShare` (already
running) push `ShareUploadView`. running) push `ShareUploadView`. `FilesView`'s own "+" → "Upload File"
(`_pickAndUploadFile`) reaches the exact same `ShareUploadView` screen
through the same `SharedFileRef`-based path (wrapping `file_picker`'s
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 `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.
`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
launch): a plain-language `AlertDialog` explaining why Noo wants it
(upload/download progress notifications - see `ShareUploadService.kt`/
`DownloadService.kt`) before the OS's own `permission_handler`-driven
`Permission.notification.request()`, since the bare system prompt gives
no context on its own.
Five of the six tabs (all but Files) plus each tab's own controls share `FloatingBottomNavBar` (`widgets/floating_bottom_bar.dart`) wraps its
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
picker, pushed rather than a tab - see below), share
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
`CustomScrollView` with a pull-down "sync status" header (Google `CustomScrollView` with a pull-down "sync status" header (Google
Photos-style) and a classic Material refresh spinner shown during a Photos-style) and a classic Material refresh spinner shown during a
pull-triggered sync. `SearchView`, `AccountView` (Settings), the pull-triggered sync. `ShareUploadView` mirrors the Files tab's own
file-details sheet, the share sheet, and `ShareUploadView` (the controls-row/breadcrumbs sticky header almost exactly, so arriving via
share-to-upload destination picker) are pushed on top via another app's "Share to..." sheet still lands on the same top chrome
instead of a plain `AppBar` - its `actions` are `[ProfileAvatarButton()]`
only (no `MoreTabsButton`: there's nowhere useful for it to go mid-upload,
since jumping to another tab would abandon the destination picker);
backing out is the system back gesture/button, not a bespoke close icon in
the app bar. Its bottom action - "Upload to
{folder}" - and the uploading-file-name summary above it (single line,
auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one
rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a
sheet peeking up from the bottom edge rather than a plain flat bar.
[`MarqueeTitle`](../../lib/widgets/marquee_title.dart) (`package:marquee`) is
shared with `FileViewerScreen`'s title - falls back to a plain ellipsized
`Text` when the content already fits, so short text never marquees.
The Move/Copy destination picker
([`MoveCopyDestinationPicker`](../../lib/views/move_copy_destination_picker.dart),
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
`FilesController`'s shared `currentFolderPath`/`pathStack`/`items`, and
owns its own local navigation state instead, fetching through the
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
no prior browsing session to preserve. The Move/Copy picker is pushed
*while the user is actively browsing a specific Files-tab folder*, so
reusing the shared state would strand that folder's `pathStack` under it;
its local state means popping back always lands the user exactly where
they were, untouched.
`SearchView`, `AccountView` (Settings), the file-details sheet, and the
share sheet are pushed on top via
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than `Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on
tap and cycles between saved accounts on a vertical swipe. tap and cycles between saved accounts on a vertical swipe.
Files and Photos (the two tabs with multi-select) pass their selection
toolbar into `SyncedHeaderScaffold`'s `selectionBar` param rather than
rendering it as a second sliver app bar inside their own content: while
non-null, it fully takes over the pinned top bar in place of the
sync-status chip/`actions`/pull-to-reveal quota panel, so selecting reads
as replacing the whole top chrome rather than adding a strip beneath it.
+336 -26
View File
@@ -26,9 +26,18 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
Chrome-autofill (Android's own system Autofill framework, e.g. a Chrome-autofill (Android's own system Autofill framework, e.g. a
password manager, may still work in the WebView; Chrome's own password manager, may still work in the WebView; Chrome's own
saved-password autofill specifically cannot, since that's Chrome-only). saved-password autofill specifically cannot, since that's Chrome-only).
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every `LoginWebViewView` also clears cookies (`WebViewCookieManager().
clearCookies()`) before every load, not just once - Android's WebView
`CookieManager` is a single store shared/persisted across every WebView
instance in the app process, not scoped per-controller, so without this
a second/"Add Account" login silently reuses whichever account's
Nextcloud session cookie is already there instead of prompting for
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. `SessionController` polls `LoginFlowService.poll(pollEndpoint, token)` every
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in 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 `appPassword`, a non-404 error, or a 10-minute timeout. A single dropped
connection mid-poll (`http.ClientException`) is swallowed and retried on connection mid-poll (`http.ClientException`) is swallowed and retried on
the next tick rather than aborting the whole flow. the next tick rather than aborting the whole flow.
@@ -53,7 +62,7 @@ below.
[`NextcloudService`](../../lib/services/nextcloud_service.dart) is the [`NextcloudService`](../../lib/services/nextcloud_service.dart) is the
client for an authenticated session — constructed with `serverUrl` + client for an authenticated session — constructed with `serverUrl` +
`username` + the app password, one instance per login (held as `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 effectively stateless per-instance (three final fields, headers rebuilt per
request), which is what makes it trivial to have one saved per account request), which is what makes it trivial to have one saved per account
rather than needing a rewrite for multi-account support. rather than needing a rewrite for multi-account support.
@@ -67,18 +76,95 @@ rather than needing a rewrite for multi-account support.
- **Everything else** (shares, activity, trash, favorites, quota, user info, - **Everything else** (shares, activity, trash, favorites, quota, user info,
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
in, with the `OCS-APIRequest: true` header required on every OCS call. in, with the `OCS-APIRequest: true` header required on every OCS call.
*Toggling* a favorite is OCS; *listing* every favorite (`fetchFavorites`,
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`/`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
(`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,
the filter dropped every non-favorited item *including folders*, so a
non-favorited folder (containing a favorited item nested inside)
vanished from the listing entirely, with no way to navigate into it;
fixing that by exempting folders from the filter was itself wrong,
because the actual intent was for favorites-only to show every
favorited item *account-wide*, not just direct children of whatever
folder was open - a strict filter over the wrong scope. Converting it to
a real tab, backed by an account-wide fetch, was the actual fix; keep
favorites account-wide rather than reintroducing a current-folder-scoped
filter.
- Auth header is HTTP Basic (`username:appPassword`, base64), built in - Auth header is HTTP Basic (`username:appPassword`, base64), built in
`_headers`/exposed as `authHeaders` for widgets that need to hit URLs `_headers`/exposed as `authHeaders` for widgets that need to hit URLs
directly (e.g. `Image.network(url, headers: service.authHeaders)` for directly (e.g. `Image.network(url, headers: service.authHeaders)` for
thumbnails/previews). thumbnails/previews).
- Downloads stream through `Dio` (`downloadToFile`) for progress callbacks; - `NextcloudService.downloadToFile` (`Dio`, progress callbacks) is still
small in-app previews (text/PDF) use `fetchBytes` via `package:http`. used for small in-app-only downloads: text/PDF previews (`fetchBytes` via
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath, `package:http`) and "open externally" (`FileViewerScreen._downloadToTemp`
{onProgress})`) stream the local file via `Dio().put()` with an explicit streams to the app's own cache dir so `open_file` can hand it to another
`Content-Length` and `onSendProgress`, mirroring the download path. The app). Explicitly saving a file **to the device** - the "Download" action
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the in Files/Photos' selection toolbar and the media viewer's download button
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination - instead hands off to `DownloadService.kt`, an Android foreground
by navigating there first (`navigateToAbsoluteFolder`), then uploading. service, the same way "Share to Noo" hands its upload off to
`ShareUploadService.kt` (see that section below) rather than downloading
in Dart and prompting `file_saver` per file: a real Service survives the
app being closed mid-download, with one cancellable notification for the
whole batch. `DownloadService.kt` re-implements a plain WebDAV GET in
Kotlin for the same reason `ShareUploadService.kt`'s PUT does - keep
`NextcloudService.downloadToFile` in sync manually if download semantics
change - and writes straight into the device's public Downloads
collection via `MediaStore.Downloads` (API 29+; a background Service
can't prompt `file_saver`'s SAF picker the way the Flutter/Activity side
can, so this is the direct equivalent) with a legacy
`Environment.DIRECTORY_DOWNLOADS` file-write fallback pre-Android-10.
Folders are filtered out client-side before handing off (no recursive/
zip download support); `DetailsVersionsTab`'s version-restore download
and `ShareSheet`'s "Share file directly" (native OS share, not saved to
Downloads) keep using `downloadToFile` directly instead, since both need
the bytes in-app rather than saved to Downloads.
- **Move/Copy** (`moveItem`/`copyItem(itemPath, destFolderPath, {overwrite,
newName})`) share one private `_moveOrCopy` helper with `renameItem`
(itself just a same-folder MOVE) - WebDAV `MOVE`/`COPY` are the same
request shape, just a different verb, and both are recursive by default
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 `ItemOperations.moveItems`/
`copyItems` need to detect without a separate existence-check request
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
hit; resolving picks overwrite/keep-both (auto-renamed via
`_nextAvailableName` against a fresh listing of the destination, fetched
once up front, not per item)/skip per item via `resolveConflicts`. The
destination-picker screen behind this
(`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`'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 - **Receiving a shared file from another app**: hand-rolled in
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
`android:launchMode` `singleTask` in the manifest so a second share while `android:launchMode` `singleTask` in the manifest so a second share while
@@ -94,7 +180,7 @@ rather than needing a rewrite for multi-account support.
story. The fix: `getInitialShare`/`onNewShare` only ever query cheap Uri story. The fix: `getInitialShare`/`onNewShare` only ever query cheap Uri
metadata (name/size/mime, not content) so `ShareUploadView`'s destination metadata (name/size/mime, not content) so `ShareUploadView`'s destination
picker - which mirrors the Files tab's own controls/filters/listing, 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. always appears instantly regardless of file size.
- **Uploading a shared file**: once the user picks a destination in - **Uploading a shared file**: once the user picks a destination in
`ShareUploadView`, [`UploadService`](../../lib/services/upload_service.dart) `ShareUploadView`, [`UploadService`](../../lib/services/upload_service.dart)
@@ -119,10 +205,224 @@ rather than needing a rewrite for multi-account support.
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to `AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to
the Dart UI, by design, since the app may not even be running. the Dart UI, by design, since the app may not even be running.
## Being picked by other apps (photo/file picker)
Noo can also be launched *by* another app as a `GET_CONTENT` picker (e.g.
Google Drive/Instagram's "choose a file" flow), the reverse direction of
"Share to Noo" above - hand-rolled the same way, not a plugin.
- `MainActivity.kt` matches `ACTION_GET_CONTENT` (`OPENABLE`, any
mimeType - a single `*/*` filter, since Android matches it against
whatever the caller actually requested) alongside its existing
`ACTION_SEND`/`SEND_MULTIPLE` filters, and exposes the caller's requested
mimeType/multi-select flag/app label via the
`dev.ayushya.noo/pick_intent` method+event channel pair
([`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.
- `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`) -
`FilesView`/`PhotosView` route taps through
`itemMatchesPickFilter`/`confirmPick` instead of their normal
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`).
- `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
prefixing it with the id to dodge collisions between same-named items;
the caller reads that on-disk name back as the display name, so
prefixing it there was a real bug (Drive showing e.g. `163332_photo.jpg`
instead of `photo.jpg`) - then hands the local paths to
`PickIntentService.finishPick`, which calls back into
`MainActivity.kt.finishPick`: it wraps each file in a `content://` Uri
via this app's own `FileProvider` (`${applicationId}.picker.fileprovider`,
scoped to just that cache subfolder - see `android/app/src/main/res/xml/
file_paths.xml`) and returns it to the caller via `setResult`. Single
file uses `setDataAndType` (never `.data =`/`.type =` as two separate
calls - each one silently nulls out the other field on a plain
`Intent`); multiple files use `ClipData`. `cancelPick` mirrors this for
backing out (system back while picking, or a picked-item mismatch) with
`RESULT_CANCELED` instead.
## Device sync
Mirrors selected folders to app-private local storage
(`getExternalFilesDir(null)/sync/<accountId>/...` - wiped on uninstall, no
extra storage permission needed) and keeps them updated in the background,
even with the app fully closed. Same rationale as the upload/download
services for going native instead of a Dart background-task plugin (see
above): a periodic job has to run without the Flutter engine loaded, and a
notification action has to resolve without launching the UI. Rather than
add `workmanager` (whose Dart `callbackDispatcher` spins up a second,
minimal Flutter engine that has to re-register every plugin it touches),
the whole engine is plain Kotlin using Android's WorkManager directly.
- [`SyncEngine.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt)
holds the shared primitives, reused by both workers below: a Depth-1 (or
Depth-0, for refreshing one item) PROPFIND (`propfindChildren`/
`propfindSelf`) requesting `d:getetag` alongside the usual props - unlike
every PROPFIND in `nextcloud_service.dart`, which never requests it
(`etag` is a dead field on `NextcloudItem` today); plain
`HttpURLConnection` GET/PUT (`downloadFile`/`uploadFile`, same style as
`DownloadService.kt`/`ShareUploadService.kt`); and `diffFolder`, which
compares one folder's freshly-walked manifest against the persisted
per-account sync-state map (native `SharedPreferences`, JSON keyed by
`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).
- [`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
a file or a folder - a folder gets the full recursive
`walkRemoteTree`, a file is diffed directly as a one-item list (nothing
else about `diffFolder`/download/upload/delete cares whether its entries
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.
- **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
`MainActivity` share one process) that `SyncWorker`/`ConflictResolveWorker`
publish into (syncing started/stopped, which `fileId`s are mid-transfer
right now, new/resolved conflicts) and `MainActivity.kt`'s
`dev.ayushya.noo/sync_service/status` `EventChannel` forwards to Dart,
same pattern as the share/pick-intent channels. It deliberately does
*not* track "which files are already synced" itself - that's read fresh
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 `SyncStatusController`
right after login/account-switch) and `SyncService.statusStream` (live)
both return the same snapshot shape. `SyncStatusController.syncHeaderStatus`
(off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent
chip/panel, replacing what used to be the WebDAV-refresh-loading
indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict
- drives the small corner badge on Files' tiles, `SyncStatusBadge`) are
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,
`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
service-targeted) to
[`SyncConflictReceiver`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt)
- a manifest-registered `BroadcastReceiver` (works even with the app
process dead) that can't itself block on network, so it just dismisses
the notification and enqueues a one-shot
[`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.
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
(`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/
`syncOnCellular` change, and `SyncService.cancel` on logout/last-account-
removed. The network constraint is `NetworkType.UNMETERED` by default
(`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's
opted into cellular sync. 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`); 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). `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. 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",
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).
- **`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
release builds (`FlutterPlugin.kt` sets `isMinifyEnabled = true`
unconditionally for the `release` build type, and auto-wires this exact
file if it exists - nothing in this project's own `build.gradle.kts`
opts into it), which broke device sync on real-device testing **twice**
in a row: first `WorkDatabase` (WorkManager locates its bundled Room
database by reflecting off the abstract database class's own,
possibly-renamed, name), then - after narrowly keeping just that class -
`OverwritingInputMerger` (WorkManager's default input merger, also
reflection-instantiated) broke the exact same way and silently ate every
`enqueueUniqueWork` call, including "Sync now", with zero indication
beyond a `WM-InputMerger` `NoSuchMethodException` in logcat - no crash,
no Dart-visible error, just a folder that stayed empty. R8's member-level
shrinking strips whatever a class's *reflection-only* callers don't
reference directly, even when the class itself survives a plain `-keep
class` with no wildcard, and WorkManager reflects into more of its own
internals than any one test pass is likely to exercise - hence the
wholesale keep instead of chasing individual classes one crash at a
time. If adding another native background component reached only via
reflection (not a manifest-declared component, which AGP already keeps
automatically), don't assume default AndroidX consumer rules cover it -
verify on an actual release build, not just `flutter analyze`/a debug
build, since minification only applies to release.
- **Files land under `Android/data/<package>/files/sync/...`
(`getExternalFilesDir`), which no third-party file manager can browse
without root** - Android's scoped storage sandboxes that whole directory
tree from other apps by design, same as any app-private storage. This
surprised real-device testing (a file manager app logged "Can't read
directory ... trying su" and came up empty even though the sync had
actually worked) - it's expected, not a bug. Confirm synced files
landed via `adb shell run-as`/a rooted shell, not a regular file
manager UI.
- **Already-synced files skip the network** in two places:
`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
file directly" and `FilesView._downloadSelected` (only when *every*
selected file is already synced - a mixed selection still goes through
the normal `DownloadService` batch) both check it first.
## Multi-account storage & session persistence ## Multi-account storage & session persistence
[`AccountStore`](../../lib/services/account_store.dart) owns everything [`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`). account is *currently* live (see `architecture.md`).
- **Per-account secrets**: one `flutter_secure_storage` key per account, - **Per-account secrets**: one `flutter_secure_storage` key per account,
@@ -141,26 +441,36 @@ account is *currently* live (see `architecture.md`).
storage scope, show-hidden ×2, Photos sort field/ascending, Files' storage scope, show-hidden ×2, Photos sort field/ascending, Files'
per-folder sort map, cache policy/interval — the full list is per-folder sort map, cache policy/interval — the full list is
`AccountStore.perAccountPrefKeys`) are namespaced `acct_<accountId>_<key>` `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 - **Legacy migration**: `AccountStore.migrateLegacyIfNeeded` runs once ever
(guarded by the `account_migration_v1_done` flag), turning a pre-multi- (guarded by the `account_migration_v1_done` flag), turning a pre-multi-
account install's 3 flat secure-storage keys + flat browsing prefs into account install's 3 flat secure-storage keys + flat browsing prefs into
the first saved (and active) account, so upgrading users are never logged the first saved (and active) account, so upgrading users are never logged
out. Never assume the legacy keys are gone — always check the migration out. Never assume the legacy keys are gone — always check the migration
flag rather than the keys' absence. flag rather than the keys' absence.
- On startup, `ServerProvider._init()` awaits the migration, loads the - On startup, `SessionController`'s constructor awaits the migration, loads
account list + active id, then `_restoreSession()` looks up the active the account list + active id, then `_restoreSession()` looks up the
account's password and calls `_applyCredentialsForAccount` (the renamed, active account's password and calls `_applyCredentialsForAccount`
generation-guarded, account-aware version of what used to be (generation-guarded, account-aware) to rebuild the session without
`_applyCredentials`) to rebuild the session without re-hitting the login re-hitting the login flow - firing `_notifyAccountActivated()` on
flow. success, which is what triggers every sibling controller's own initial
fetch.
- **Switching accounts** (`switchAccount`/`cycleToNextAccount`/ - **Switching accounts** (`switchAccount`/`cycleToNextAccount`/
`cycleToPreviousAccount`/`removeAccount`'s fallback, plus landing on a `cycleToPreviousAccount`/`removeAccount`'s fallback, plus landing on a
freshly-added account) all funnel through the single `_activateAccount` freshly-added account) all funnel through the single `_activateAccount`
engine: bump `_sessionGeneration`, cancel any pending login flow, clear engine: bump `sessionGeneration`, cancel any pending login flow, call
every content field *without* ever setting `isLoggedIn` false (that's the `_notifyAccountCleared()` (every sibling controller's registered
detail that keeps `main.dart`'s root routing from bouncing through `addAccountClearedListener` callback resets that controller's own state)
`LoginView` mid-switch), reload the target account's prefs, then verify *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 its credentials and refetch everything. This is a full teardown-and-reload
every time — there is deliberately no simultaneous multi-account state or every time — there is deliberately no simultaneous multi-account state or
background sync; only one account's content is ever live. background sync; only one account's content is ever live.
@@ -182,7 +492,7 @@ account is *currently* live (see `architecture.md`).
that's actually destructive/irreversible - UI call sites (`AccountView`) that's actually destructive/irreversible - UI call sites (`AccountView`)
gate it behind a confirmation dialog; `logout()` doesn't need one. gate it behind a confirmation dialog; `logout()` doesn't need one.
- Any UI code that calls either and might have ended the session should - 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 r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left
stranded over a root route that's silently swapped to `LoginView` stranded over a root route that's silently swapped to `LoginView`
underneath it. Don't pop unconditionally — removing a *non-active* underneath it. Don't pop unconditionally — removing a *non-active*
@@ -198,7 +508,7 @@ app. [`AppLockService`](../../lib/services/app_lock_service.dart) wraps
`authenticate()` always delegates to whatever the OS already has configured `authenticate()` always delegates to whatever the OS already has configured
(biometric, or device PIN/pattern/password as fallback, via (biometric, or device PIN/pattern/password as fallback, via
`biometricOnly: false`). Never build a custom in-app PIN screen for this — `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. `architecture.md` instead.
**Android native requirements** (both already done, keep them if you touch **Android native requirements** (both already done, keep them if you touch
+40 -27
View File
@@ -29,28 +29,35 @@ class/method already makes obvious.
- Private helpers/widgets are prefixed with `_` and live in the same file as - Private helpers/widgets are prefixed with `_` and live in the same file as
their one caller; promote to `widgets/` only once something is reused their one caller; promote to `widgets/` only once something is reused
across files. across files.
- Read provider state with `context.watch<ServerProvider>()` in `build`, - Read controller state with `context.watch<XController>()` in `build`, and
and `context.read<ServerProvider>()` for one-off calls from callbacks `context.read<XController>()` for one-off calls from callbacks (matches
(matches `LoginView._handleContinue`). `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 - Any method that fetches data and writes it into a shared field
(`refreshData`, `fetchAllMedia`, `fetchTrash`, `fetchShares`, (`FilesController.refreshData`, `PhotosController.fetchAllMedia`,
`fetchRecent`, `_applyCredentialsForAccount`) must guard against a stale `TrashController.fetchAll`, `SharesController.fetchAll`,
write from an account the user has since switched away from: capture `RecentController.fetchAll`, `SessionController._applyCredentialsForAccount`)
`final gen = _sessionGeneration;` at entry, and check must guard against a stale write from an account the user has since
`if (gen != _sessionGeneration) return;` immediately after each `await` switched away from: capture `final gen = session.sessionGeneration;` at
before touching any field or calling `notifyListeners()`. Follow this entry, and check `if (gen != session.sessionGeneration) return;`
pattern for any new fetch method added to the provider. immediately after each `await` before touching any field or calling
- New persisted state on `ServerProvider` must be classified global vs. `notifyListeners()`. Follow this pattern for any new fetch method added
per-account (see `architecture.md`/`server.md`) up front — global state to any controller.
uses a plain `_prefsFuture.then((p) => p.setX(key, value))`; per-account - New persisted state must be classified global vs. per-account (see
state goes through `_persistAccountPref(key, (p, namespacedKey) => `architecture.md`/`server.md`) up front and live on whichever controller
p.setX(namespacedKey, value))` and must also be handled in owns that domain — global state (`SettingsController`) uses a plain
`_applyAccountPrefs` (both the "reset to default when no account" and the `_prefsFuture.then((p) => p.setX(key, value))`; per-account state goes
"load for this account" branches) so it's correct immediately after a through `_persistAccountPref(key, (p, namespacedKey) =>
switch, not just at startup. 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 ## Testing
@@ -59,7 +66,7 @@ class/method already makes obvious.
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler — `MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler —
and disable Google Fonts network fetching and disable Google Fonts network fetching
(`GoogleFonts.config.allowRuntimeFetching = false`) in `setUpAll`. Without (`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 sandbox (no plugin implementation is registered, so the read future just
never completes) and the app stays on `_SplashView`'s indeterminate never completes) and the app stays on `_SplashView`'s indeterminate
spinner, which makes `pumpAndSettle()` hang until its own timeout instead spinner, which makes `pumpAndSettle()` hang until its own timeout instead
@@ -84,13 +91,19 @@ adb install -r build/app/outputs/flutter-apk/app-release.apk
``` ```
This only preserves data if the new APK's signature matches what's already This only preserves data if the new APK's signature matches what's already
on the device — a local build's release variant currently reuses the debug on the device. `android/app/build.gradle.kts` picks a release signing key
signing config (`android/app/build.gradle.kts`), so consecutive local in this order: a local `android/key.properties` (gitignored — points at a
builds share a key and `adb install -r` works cleanly. Installing a build gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
signed with a different key (e.g. a CI-signed release APK from the Gitea CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
release pipeline) over a differently-signed local build forces Android to `_PASSWORD`, set by `.gitea/workflows/build.yml` from repo secrets), then
require a full uninstall regardless of the install method used — there's no falls back to the debug key if neither is configured. As long as the same
way around that from the tooling side. dedicated release keystore backs both `key.properties` locally and the
Gitea secrets, local release builds and CI-built release APKs share one
signature, so `adb install -r` works cleanly either way. A local checkout
with no `key.properties` set up falls back to the (per-machine, ungitted)
debug key, which won't match a CI-signed APK — installing one over the
other still forces a full uninstall, since there's no way around Android's
signature check from the tooling side.
## Dependencies ## Dependencies
+27 -4
View File
@@ -42,6 +42,14 @@ widgets. Key points:
`files_view.dart` and `share_upload_view.dart`'s destination picker). `files_view.dart` and `share_upload_view.dart`'s destination picker).
Extend this rather than re-deriving per-type icons/colors at a new call Extend this rather than re-deriving per-type icons/colors at a new call
site. site.
- [`DetailsHeader`](../../lib/widgets/details/details_sheet.dart) — the
icon-box/name/meta row every per-item bottom sheet opens on
(`DetailsSheet`, the media viewer's collapsed peek state, and
`ShareSheet`). Takes a `padding` override for callers whose own scroll
view already applies horizontal insets (`ShareSheet`'s `ListView`), so it
doesn't get doubled up. Reuse this instead of a bare title `Text` for any
new per-item sheet - a plain title reads as under-designed next to the
other sheets (a real instance: `ShareSheet` used to be just that).
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) — - [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
the blurred/translucent pill background shared by all floating chrome the blurred/translucent pill background shared by all floating chrome
(bottom nav bar, media-viewer top/bottom bars and video transport (bottom nav bar, media-viewer top/bottom bars and video transport
@@ -49,13 +57,28 @@ widgets. Key points:
new blur/shadow combo. new blur/shadow combo.
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the - [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
main tab bar; opacity/blur are user-adjustable settings main tab bar; opacity/blur are user-adjustable settings
(`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull (`SettingsController.bottomBarOpacity`/`bottomBarBlur`), not constants —
new adjustable visual knobs from the provider the same way rather than pull new adjustable visual knobs from `SettingsController` the same way
hardcoding them. rather than hardcoding them.
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — - [`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 5 of the 6 tabs (see
`architecture.md`); also where the pull-to-refresh gesture thresholds and `architecture.md`); also where the pull-to-refresh gesture thresholds and
the classic Material refresh spinner live. the classic Material refresh spinner live. Its persistent compact chip
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync
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. The expanded panel's headline is a separate,
more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) -
actual folder/item counts ("2 folders & 5 items synced") rather than
just repeating the chip's generic label, which would otherwise read
"Synced" even when a folder's just been added and nothing's downloaded
yet.
- [`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'
list and grid tiles today. Reuse this rather than a new ad hoc badge if
another view starts showing sync status per item.
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) — the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
for the video player's seek bar, plus a perpetually-animated for the video player's seek bar, plus a perpetually-animated
+14
View File
@@ -43,8 +43,22 @@ jobs:
- name: Analyze - name: Analyze
run: flutter analyze run: flutter analyze
- name: Decode release keystore
# Keeps every release build - regardless of which runner built it -
# signed with the same key, so downloaded updates install cleanly
# over a previous release instead of failing with "App not
# installed" (mismatched signature). See android/app/build.gradle.kts.
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
- name: Build release APK - name: Build release APK
run: flutter build apk --release run: flutter build apk --release
env:
RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/release-keystore.jks
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
- name: Rename APK to Noo-<version>.apk - name: Rename APK to Noo-<version>.apk
run: | run: |
+2
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
/build/
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
@@ -12,3 +13,4 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
RELEASE_SIGNING_SECRETS.txt
+57 -3
View File
@@ -1,9 +1,35 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// Release signing: prefer a local android/key.properties (gitignored, for
// a developer machine that has the release keystore), then CI env vars
// (set by .gitea/workflows/build.yml from repo secrets), then null - which
// falls back to debug signing below so an unconfigured checkout still
// builds/runs fine, same as before this existed.
val releaseKeystoreProperties = Properties().apply {
val propsFile = rootProject.file("key.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
fun releaseSigningValue(propertyKey: String, envKey: String): String? =
releaseKeystoreProperties.getProperty(propertyKey) ?: System.getenv(envKey)
val releaseStoreFile = releaseSigningValue("storeFile", "RELEASE_KEYSTORE_PATH")
val releaseStorePassword = releaseSigningValue("storePassword", "RELEASE_KEYSTORE_PASSWORD")
val releaseKeyAlias = releaseSigningValue("keyAlias", "RELEASE_KEY_ALIAS")
val releaseKeyPassword = releaseSigningValue("keyPassword", "RELEASE_KEY_PASSWORD")
val hasReleaseSigning = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
android { android {
namespace = "dev.ayushya.noo" namespace = "dev.ayushya.noo"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -27,11 +53,27 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Falls back to the debug keystore (so an unconfigured
// Signing with the debug keys for now, so `flutter run --release` works. // checkout can still `flutter run --release`) when no release
signingConfig = signingConfigs.getByName("debug") // signing config is available - see the comment above.
signingConfig = if (hasReleaseSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
} }
@@ -42,6 +84,18 @@ kotlin {
} }
} }
dependencies {
// Device sync's periodic/one-off background jobs (SyncWorker,
// ConflictResolveWorker) - see their doc comments for why this is
// plain WorkManager rather than a Dart-side background-task plugin.
implementation("androidx.work:work-runtime-ktx:2.9.1")
// PROPFIND (WebDAV directory listing) - Android's HttpURLConnection
// hard-rejects any method outside {OPTIONS,GET,HEAD,POST,PUT,DELETE,
// TRACE,PATCH} (ProtocolException), unlike plain OpenJDK. OkHttp has
// no such whitelist. See SyncEngine.kt.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
flutter { flutter {
source = "../.." source = "../.."
} }
+18
View File
@@ -0,0 +1,18 @@
# Device sync (SyncWorker/ConflictResolveWorker) - WorkManager instantiates
# a lot of its own internals via reflection (Workers by class name recorded
# at enqueue time, its bundled Room database's *_Impl class off the
# abstract database class's own name, its default InputMerger, etc). R8's
# member-level shrinking silently breaks any of these - e.g. stripping an
# "unused" no-arg constructor - even when the class itself survives a
# plain `-keep class` with no wildcard, and narrowly keeping only the
# classes hit by one test pass just means the next reflection path (a
# different WorkManager-internal class) breaks instead. Two separate
# instances of this already bit real testing (WorkDatabase, then
# OverwritingInputMerger) before landing on this broad keep - see
# android/app/build.gradle.kts (androidx.work dependency) and
# .claude/context/server.md's "Device sync" section.
-keep class androidx.work.** { *; }
-keep class * extends androidx.room.RoomDatabase { *; }
-keep class dev.ayushya.noo.SyncWorker { *; }
-keep class dev.ayushya.noo.ConflictResolveWorker { *; }
-keep class dev.ayushya.noo.SyncConflictReceiver { *; }
+44 -3
View File
@@ -2,12 +2,19 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- For local_auth's biometric app-lock (Settings > Security). --> <!-- For local_auth's biometric app-lock (Settings > Security). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/> <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<!-- For ShareUploadService, which keeps a "Share to Noo" upload going <!-- For ShareUploadService/DownloadService, which keep a "Share to Noo"
(and cancellable from its notification) even if the app is fully upload/download going (and cancellable from its notification) even
closed - see the service's own doc comment. --> if the app is fully closed - see each service's own doc comment. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- DownloadService's pre-Android-10 fallback only (MediaStore.Downloads
needs no runtime permission on API 29+, which is what targetSdk
actually enforces) - capped with maxSdkVersion so it's not even
requested on newer devices. -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application <application
android:label="Noo" android:label="Noo"
android:name="${applicationName}" android:name="${applicationName}"
@@ -49,11 +56,45 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/> <data android:mimeType="*/*"/>
</intent-filter> </intent-filter>
<!-- Lets Noo be picked as a source ("Google Photos", "Files", ...)
from another app's own file/photo picker chooser - handled by
MainActivity.kt/PickIntentService, not a plugin. A single
"*/*" filter matches any mimeType the caller requests
(image/*, video/*, or a generic file pick). -->
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.OPENABLE"/>
<data android:mimeType="*/*"/>
</intent-filter>
</activity> </activity>
<service <service
android:name=".ShareUploadService" android:name=".ShareUploadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync"/> android:foregroundServiceType="dataSync"/>
<service
android:name=".DownloadService"
android:exported="false"
android:foregroundServiceType="dataSync"/>
<!-- The "Keep local"/"Use server" actions on a sync-conflict
notification (SyncWorker/SyncEngine) - must be a manifest-
registered receiver, not a runtime-registered one, since it
needs to work even when the app process isn't running. -->
<receiver
android:name=".SyncConflictReceiver"
android:exported="false"/>
<!-- Hands back the file(s) a caller picked via the GET_CONTENT filter
above - only the app's own cache/picker/ subfolder is exposed,
see file_paths.xml. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.picker.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
@@ -0,0 +1,101 @@
package dev.ayushya.noo
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Resolves one sync conflict, triggered either by [SyncConflictReceiver]
* (a notification action) or directly from Dart (in-app resolution via the
* `dev.ayushya.noo/sync_service` MethodChannel's `resolveConflict`, see
* `MainActivity.kt`) - "local" pushes the on-device copy up (overwriting
* the server), "server" pulls the server copy down (overwriting the local
* mirror). Either way, the sync-state entry for this file is refreshed
* afterward so the next [SyncWorker] pass doesn't immediately re-flag it.
*/
class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
companion object {
const val KEY_ACCOUNT_ID = "accountId"
const val KEY_SERVER_URL = "serverUrl"
const val KEY_USERNAME = "username"
const val KEY_AUTH_HEADER = "authHeader"
const val KEY_FILE_ID = "fileId"
const val KEY_REMOTE_PATH = "remotePath"
const val KEY_REL_PATH = "relPath"
const val KEY_RESOLUTION = "resolution"
/** The one place a resolution gets enqueued - both callers above share it. */
fun enqueue(
context: Context,
accountId: String?,
serverUrl: String?,
username: String?,
authHeader: String?,
fileId: String?,
remotePath: String?,
relPath: String?,
resolution: String?,
) {
val data = workDataOf(
KEY_ACCOUNT_ID to accountId,
KEY_SERVER_URL to serverUrl,
KEY_USERNAME to username,
KEY_AUTH_HEADER to authHeader,
KEY_FILE_ID to fileId,
KEY_REMOTE_PATH to remotePath,
KEY_REL_PATH to relPath,
KEY_RESOLUTION to resolution,
)
val request = OneTimeWorkRequestBuilder<ConflictResolveWorker>()
.setInputData(data)
.build()
WorkManager.getInstance(context).enqueue(request)
}
}
override suspend fun doWork(): 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 fileId = inputData.getString(KEY_FILE_ID) ?: return@withContext Result.failure()
val remotePath = inputData.getString(KEY_REMOTE_PATH) ?: return@withContext Result.failure()
val relPath = inputData.getString(KEY_REL_PATH) ?: return@withContext Result.failure()
val resolution = inputData.getString(KEY_RESOLUTION) ?: return@withContext Result.failure()
val syncRoot = SyncEngine.syncRoot(applicationContext, accountId)
val localFile = File(syncRoot, relPath)
val ok = when (resolution) {
"local" -> localFile.exists() &&
SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile)
"server" -> SyncEngine.downloadFile(serverUrl, username, authHeader, remotePath, localFile)
else -> false
}
if (!ok) return@withContext Result.retry()
// 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 state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
state[fileId] = SyncEngine.FileState(
relPath = relPath,
etag = fresh?.etag ?: "",
lastModified = fresh?.lastModified ?: 0L,
size = localFile.length(),
localMTime = localFile.lastModified(),
)
SyncEngine.saveState(applicationContext, accountId, state)
SyncStatusBus.removeConflict(accountId, fileId)
Result.success()
}
}
@@ -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)
}
}
}
}
}
@@ -0,0 +1,276 @@
package dev.ayushya.noo
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.provider.MediaStore
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import org.json.JSONArray
import java.io.File
import java.net.URL
/**
* 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: 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; a second batch arriving mid-download queues behind the
* first (see [TransferQueue]) instead of being dropped.
*/
class DownloadService : Service() {
companion object {
const val ACTION_CANCEL = "dev.ayushya.noo.action.CANCEL_DOWNLOAD"
const val EXTRA_FILES = "files" // JSON array of {path, name, mimeType, size}
const val EXTRA_SERVER_URL = "serverUrl"
const val EXTRA_USERNAME = "username"
const val EXTRA_AUTH_HEADER = "authHeader"
private const val CHANNEL_ID = "file_downloads"
private const val NOTIFICATION_ID = 4301
}
private data class DownloadFile(
val path: String,
val name: String,
val mimeType: String?,
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 = true
queue.clearPending()
return START_NOT_STICKY
}
val filesJson = intent?.getStringExtra(EXTRA_FILES)
val serverUrl = intent?.getStringExtra(EXTRA_SERVER_URL)
val username = intent?.getStringExtra(EXTRA_USERNAME)
val authHeader = intent?.getStringExtra(EXTRA_AUTH_HEADER)
if (filesJson == null || serverUrl == null || username == null || authHeader == null) {
stopSelf()
return START_NOT_STICKY
}
NooNotificationChannels.ensure(
this,
CHANNEL_ID,
"File downloads",
NotificationManager.IMPORTANCE_LOW,
"Progress for files downloaded from Noo",
)
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildProgressNotification("Preparing to download…", null, indeterminate = true),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
},
)
queue.enqueue(DownloadBatch(parseFiles(filesJson), serverUrl, username, authHeader))
return START_NOT_STICKY
}
private fun parseFiles(json: String): List<DownloadFile> {
val arr = JSONArray(json)
return (0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
DownloadFile(
path = obj.getString("path"),
name = obj.getString("name"),
mimeType = if (obj.isNull("mimeType")) null else obj.getString("mimeType"),
size = if (obj.isNull("size")) null else obj.getLong("size"),
)
}
}
private fun runDownloads(batch: DownloadBatch) {
cancelled = false
var succeeded = 0
var failed = 0
val cleanServer = batch.serverUrl.trimEnd('/')
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/${batch.username}$encodedPath")
val ok = downloadFile(file, url, batch.authHeader) { received, total ->
notify(
buildProgressNotification(
"Downloading $label…",
progressFraction(received, total),
indeterminate = total == null,
),
)
}
if (ok) succeeded++ else failed++
} catch (e: Exception) {
failed++
}
}
val finalText = when {
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))
if (queue.pendingCount == 0) {
stopForeground(STOP_FOREGROUND_DETACH)
stopSelf()
}
}
private fun progressFraction(received: Long, total: Long?): Float? {
if (total == null || total <= 0) return null
return (received.toFloat() / total.toFloat()).coerceIn(0f, 1f)
}
/// Streams the GET response straight into the public Downloads
/// collection via MediaStore (API 29+) - scoped storage means a
/// background Service can't prompt a SAF picker the way `file_saver`
/// does from the Flutter/Activity side, so this writes directly to the
/// Downloads collection instead, same place a browser download lands.
/// Pre-Q devices fall back to the legacy public Downloads directory.
private fun downloadFile(
file: DownloadFile,
url: URL,
authHeader: String,
onProgress: (Long, Long?) -> Unit,
): Boolean {
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(uri, values, null, null)
}
if (!ok) contentResolver.delete(uri, null, null)
}
return ok
}
private fun createDownloadsEntry(name: String, mimeType: String): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.IS_PENDING, 1)
}
contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
} else {
@Suppress("DEPRECATION")
val downloadsDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
Uri.fromFile(File(downloadsDir, name))
}
}
private fun manager() =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private fun notify(notification: Notification) {
manager().notify(NOTIFICATION_ID, notification)
}
private fun buildProgressNotification(
text: String,
progress: Float?,
indeterminate: Boolean,
): Notification {
val cancelIntent = Intent(this, DownloadService::class.java).apply { action = ACTION_CANCEL }
val cancelPendingIntent = PendingIntent.getService(
this,
0,
cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("Downloading from Noo")
.setContentText(text)
.setOnlyAlertOnce(true)
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
when {
indeterminate -> builder.setProgress(100, 0, true)
progress != null -> builder.setProgress(100, (progress * 100).toInt(), false)
}
return builder.build()
}
private fun buildFinalNotification(text: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("Noo")
.setContentText(text)
.setOngoing(false)
.setAutoCancel(true)
.build()
}
override fun onDestroy() {
cancelled = true
super.onDestroy()
}
}
@@ -1,6 +1,7 @@
package dev.ayushya.noo package dev.ayushya.noo
import android.Manifest import android.Manifest
import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@@ -10,11 +11,23 @@ import android.os.Looper
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import org.json.JSONArray
import java.io.File
import java.util.concurrent.TimeUnit
// FlutterFragmentActivity (not the default FlutterActivity) is required by // FlutterFragmentActivity (not the default FlutterActivity) is required by
// local_auth's Android implementation, which hosts its biometric/device // local_auth's Android implementation, which hosts its biometric/device
@@ -38,10 +51,21 @@ class MainActivity : FlutterFragmentActivity() {
private val shareIntentChannelName = "dev.ayushya.noo/share_intent" private val shareIntentChannelName = "dev.ayushya.noo/share_intent"
private val newShareChannelName = "dev.ayushya.noo/share_intent/new" private val newShareChannelName = "dev.ayushya.noo/share_intent/new"
private val uploadServiceChannelName = "dev.ayushya.noo/upload_service" private val uploadServiceChannelName = "dev.ayushya.noo/upload_service"
private val downloadServiceChannelName = "dev.ayushya.noo/download_service"
private val pickIntentChannelName = "dev.ayushya.noo/pick_intent"
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
private val notificationPermissionRequestCode = 4202 private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the
// Activity's base Context, which isn't attached yet while this class's
// fields are being constructed (crashes with an NPE if evaluated then).
private val pickerFileProviderAuthority by lazy { "$packageName.picker.fileprovider" }
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var newShareSink: EventChannel.EventSink? = null private var newShareSink: EventChannel.EventSink? = null
private var newPickSink: EventChannel.EventSink? = null
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -73,6 +97,105 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadServiceChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"startDownload" -> {
startDownloadService(call, result)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, pickIntentChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"getPickRequest" -> result.success(extractPickRequest(intent))
"finishPick" -> finishPick(call, result)
"cancelPick" -> cancelPick(result)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, newPickChannelName)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
newPickSink = events
}
override fun onCancel(arguments: Any?) {
newPickSink = null
}
})
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, syncServiceChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"reschedule" -> rescheduleSyncWork(call, result)
"cancel" -> cancelSyncWork(result)
"syncNow" -> syncNow(call, result)
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
"resolveConflict" -> resolveConflict(call, result)
"removeLocalSync" -> removeLocalSync(call, result)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, syncStatusChannelName)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val listener: (SyncStatusBus.Status) -> Unit = { status ->
mainHandler.post { events.success(syncStatusMap(status)) }
}
syncStatusListener = listener
SyncStatusBus.subscribe(listener)
}
override fun onCancel(arguments: Any?) {
syncStatusListener?.let { SyncStatusBus.unsubscribe(it) }
syncStatusListener = null
}
})
}
private fun syncStatusMap(status: SyncStatusBus.Status): Map<String, Any?> {
val syncedFileIds = status.accountId?.let {
SyncEngine.loadState(applicationContext, it).keys.toList()
} ?: emptyList()
return mapOf(
"accountId" to status.accountId,
"syncing" to status.syncing,
"syncingFileIds" to status.syncingFileIds.toList(),
"syncedFileIds" to syncedFileIds,
"conflicts" to status.conflicts.map {
mapOf(
"accountId" to it.accountId,
"fileId" to it.fileId,
"remotePath" to it.remotePath,
"relPath" to it.relPath,
"name" to it.name,
)
},
)
}
/// In-app conflict resolution (the header's "Keep local"/"Use server"
/// buttons) - shares [ConflictResolveWorker.enqueue] with
/// [SyncConflictReceiver], the only other caller, so there's one
/// resolution code path regardless of whether it's triggered from a
/// notification or from inside the app.
private fun resolveConflict(call: MethodCall, result: MethodChannel.Result) {
ConflictResolveWorker.enqueue(
this,
accountId = call.argument<String>("accountId"),
serverUrl = call.argument<String>("serverUrl"),
username = call.argument<String>("username"),
authHeader = call.argument<String>("authHeader"),
fileId = call.argument<String>("fileId"),
remotePath = call.argument<String>("remotePath"),
relPath = call.argument<String>("relPath"),
resolution = call.argument<String>("resolution"),
)
result.success(null)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -82,6 +205,10 @@ class MainActivity : FlutterFragmentActivity() {
if (shared.isNotEmpty()) { if (shared.isNotEmpty()) {
mainHandler.post { newShareSink?.success(shared) } mainHandler.post { newShareSink?.success(shared) }
} }
val pickRequest = extractPickRequest(intent)
if (pickRequest != null) {
mainHandler.post { newPickSink?.success(pickRequest) }
}
} }
/// Cheap Uri metadata only (display name / size / mime type) via a /// Cheap Uri metadata only (display name / size / mime type) via a
@@ -180,4 +307,206 @@ class MainActivity : FlutterFragmentActivity() {
ContextCompat.startForegroundService(this, serviceIntent) ContextCompat.startForegroundService(this, serviceIntent)
result.success(null) result.success(null)
} }
/// Starts DownloadService with everything it needs to run entirely on
/// its own (see its doc comment) - same contract shape as
/// [startUploadService], kept in sync manually for the same reason.
private fun startDownloadService(call: MethodCall, result: MethodChannel.Result) {
val filesJson = call.argument<String>("files")
val serverUrl = call.argument<String>("serverUrl")
val username = call.argument<String>("username")
val authHeader = call.argument<String>("authHeader")
if (filesJson == null || serverUrl == null || username == null || authHeader == null) {
result.error("bad_args", "Missing required download arguments", null)
return
}
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
// Fire-and-forget: the service works fine even if this is denied,
// it just won't be able to show progress/cancel in a notification.
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
notificationPermissionRequestCode,
)
}
val serviceIntent = Intent(this, DownloadService::class.java).apply {
putExtra(DownloadService.EXTRA_FILES, filesJson)
putExtra(DownloadService.EXTRA_SERVER_URL, serverUrl)
putExtra(DownloadService.EXTRA_USERNAME, username)
putExtra(DownloadService.EXTRA_AUTH_HEADER, authHeader)
}
ContextCompat.startForegroundService(this, serviceIntent)
result.success(null)
}
/// 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? {
val accountId = call.argument<String>("accountId")
val serverUrl = call.argument<String>("serverUrl")
val username = call.argument<String>("username")
val authHeader = call.argument<String>("authHeader")
val foldersJson = call.argument<String>("folders")
if (accountId == null || serverUrl == null || username == null ||
authHeader == null || foldersJson == null
) {
return null
}
return workDataOf(
SyncWorker.KEY_ACCOUNT_ID to accountId,
SyncWorker.KEY_SERVER_URL to serverUrl,
SyncWorker.KEY_USERNAME to username,
SyncWorker.KEY_AUTH_HEADER to authHeader,
SyncWorker.KEY_FOLDERS to foldersJson,
)
}
/// Re-enqueues (or cancels, if the folder list is now empty) the
/// periodic device-sync job - called from Dart whenever the synced-
/// folder list, active account, or Wi-Fi-only setting changes, since a
/// 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 data = syncWorkData(call)
val foldersJson = call.argument<String>("folders")
val wifiOnly = call.argument<Boolean>("wifiOnly") ?: true
val workManager = WorkManager.getInstance(this)
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0) {
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
result.success(null)
return
}
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)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
SyncWorker.UNIQUE_PERIODIC_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
result.success(null)
}
private fun cancelSyncWork(result: MethodChannel.Result) {
WorkManager.getInstance(this).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)
if (data == null) {
result.error("bad_args", "Missing required sync arguments", null)
return
}
val request = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data).build()
WorkManager.getInstance(this).enqueueUniqueWork(
SyncWorker.UNIQUE_ONE_OFF_NAME,
ExistingWorkPolicy.REPLACE,
request,
)
result.success(null)
}
/// Non-null only when this Activity was launched (or re-delivered a new
/// Intent) as another app's GET_CONTENT picker - the mimeType filter and
/// multi-select flag the caller asked for, plus a best-effort display
/// name for the calling app to show in the "picking mode" banner.
private fun extractPickRequest(intent: Intent): Map<String, Any?>? {
if (intent.action != Intent.ACTION_GET_CONTENT) return null
val mimeType = intent.type ?: "*/*"
val allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
val callerPackage = callingPackage
val callerLabel = callerPackage?.let {
try {
val appInfo = packageManager.getApplicationInfo(it, 0)
packageManager.getApplicationLabel(appInfo).toString()
} catch (_: PackageManager.NameNotFoundException) {
it
}
}
return mapOf(
"mimeType" to mimeType,
"allowMultiple" to allowMultiple,
"callerLabel" to callerLabel,
)
}
/// Hands the already-downloaded local files (see PickIntentService/
/// ServerProvider.confirmPick - this Activity never touches the
/// Nextcloud server itself) back to the caller as content:// Uris
/// through this app's own FileProvider, then closes the picker.
private fun finishPick(call: MethodCall, result: MethodChannel.Result) {
val paths = call.argument<List<String>>("paths")
val mimeTypes = call.argument<List<String>>("mimeTypes") ?: emptyList()
if (paths.isNullOrEmpty()) {
result.error("no_files", "No files to return to the caller", null)
return
}
val uris = paths.map { path ->
FileProvider.getUriForFile(this, pickerFileProviderAuthority, File(path))
}
val resultIntent = Intent().apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// `Intent.setData()` and `.setType()` each silently null out the
// other field if called separately (Android framework behavior) -
// `setDataAndType` is the only way to have both survive on the same
// Intent, which every GET_CONTENT caller expects for `data`.
val firstMimeType = mimeTypes.getOrNull(0) ?: "*/*"
if (uris.size == 1) {
resultIntent.setDataAndType(uris[0], firstMimeType)
} else {
val clipMimeTypes = mimeTypes.ifEmpty { listOf("*/*") }.toTypedArray()
val clipData = ClipData("Noo picked files", clipMimeTypes, ClipData.Item(uris[0]))
for (i in 1 until uris.size) clipData.addItem(ClipData.Item(uris[i]))
resultIntent.clipData = clipData
// Some callers only read `data` even for a multi-item pick, so
// this still points at the first file as a fallback.
resultIntent.setDataAndType(uris[0], firstMimeType)
}
setResult(RESULT_OK, resultIntent)
result.success(null)
finish()
}
private fun cancelPick(result: MethodChannel.Result) {
setResult(RESULT_CANCELED)
result.success(null)
finish()
}
} }
@@ -0,0 +1,29 @@
package dev.ayushya.noo
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
/**
* Shared notification-channel creation - [DownloadService],
* [ShareUploadService], and [SyncWorker] each independently re-implemented
* this same `if (SDK_INT >= O) { NotificationChannel(...); create }` shape
* before this existed.
*/
object NooNotificationChannels {
fun ensure(
context: Context,
id: String,
name: String,
importance: Int,
description: String,
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(id, name, importance).apply {
this.description = description
}
manager.createNotificationChannel(channel)
}
}
@@ -1,7 +1,6 @@
package dev.ayushya.noo package dev.ayushya.noo
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
@@ -16,9 +15,7 @@ import androidx.core.app.ServiceCompat
import org.json.JSONArray import org.json.JSONArray
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* Foreground service that prepares (copies from a content:// Uri) and * 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 * doesn't interrupt the upload, the same way a file-manager app's own
* upload notification survives the app being closed. * upload notification survives the app being closed.
* *
* Re-implements a plain WebDAV PUT here in Kotlin (HttpURLConnection, no * PUT itself is [DavTransfer.put]; this file owns only what's specific to
* new HTTP dependency) rather than reusing NextcloudService/Dio from Dart, * a share-upload - materializing the source content:// Uri into a real
* since a Dart isolate doesn't keep running once the Flutter engine/ * file first (WebDAV PUT needs a known Content-Length, which streaming
* Activity are gone - only a real Android Service does. This does mean the * straight from a content:// Uri can't always provide) and the
* PUT request itself is duplicated logic (see NextcloudService. * notification/queue plumbing.
* uploadFileFromPath); keep both in sync if the upload semantics change.
* *
* Started via the `dev.ayushya.noo/upload_service` MethodChannel * Started via the `dev.ayushya.noo/upload_service` MethodChannel
* (MainActivity.kt) with credentials/destination passed as Intent extras - * (MainActivity.kt) with credentials/destination passed as Intent extras -
* never has an Activity in the loop after that. Shows one persistent, * never has an Activity in the loop after that. Shows one persistent,
* cancellable notification for the whole batch; the Cancel action re-enters * cancellable notification for the whole batch; a second share arriving
* this same running service instance with [ACTION_CANCEL], which the * mid-upload queues behind the first (see [TransferQueue]) instead of
* copy/upload loops poll. * being dropped.
*/ */
class ShareUploadService : Service() { class ShareUploadService : Service() {
companion object { companion object {
@@ -55,16 +51,26 @@ class ShareUploadService : Service() {
private const val NOTIFICATION_ID = 4201 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 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 onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) { if (intent?.action == ACTION_CANCEL) {
cancelled.set(true) cancelled = true
queue.clearPending()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -80,7 +86,13 @@ class ShareUploadService : Service() {
return START_NOT_STICKY return START_NOT_STICKY
} }
createNotificationChannel() NooNotificationChannels.ensure(
this,
CHANNEL_ID,
"File uploads",
NotificationManager.IMPORTANCE_LOW,
"Progress for files shared to Noo",
)
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
NOTIFICATION_ID, NOTIFICATION_ID,
@@ -92,16 +104,7 @@ class ShareUploadService : Service() {
}, },
) )
// A second share arriving mid-upload is dropped rather than queued - queue.enqueue(UploadBatch(parseFiles(filesJson), serverUrl, username, authHeader, remoteFolder))
// 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() }
}
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -117,23 +120,22 @@ class ShareUploadService : Service() {
} }
} }
private fun runUploads( private fun runUploads(batch: UploadBatch) {
files: List<ShareFile>, cancelled = false
serverUrl: String,
username: String,
authHeader: String,
remoteFolder: String,
) {
var succeeded = 0 var succeeded = 0
var failed = 0 var failed = 0
val cleanServer = serverUrl.trimEnd('/') val cleanServer = batch.serverUrl.trimEnd('/')
var cleanFolder = remoteFolder.trim() var cleanFolder = batch.remoteFolder.trim()
if (!cleanFolder.startsWith("/")) cleanFolder = "/$cleanFolder" if (!cleanFolder.startsWith("/")) cleanFolder = "/$cleanFolder"
if (!cleanFolder.endsWith("/")) cleanFolder = "$cleanFolder/" if (!cleanFolder.endsWith("/")) cleanFolder = "$cleanFolder/"
for ((index, file) in files.withIndex()) { for ((index, file) in batch.files.withIndex()) {
if (cancelled.get()) break if (cancelled) break
val label = if (files.size == 1) file.name else "${file.name} (${index + 1}/${files.size})" val label = if (batch.files.size == 1) {
file.name
} else {
"${file.name} (${index + 1}/${batch.files.size})"
}
var tempFile: File? = null var tempFile: File? = null
try { try {
notify(buildProgressNotification("Preparing $label…", null, indeterminate = true)) notify(buildProgressNotification("Preparing $label…", null, indeterminate = true))
@@ -146,19 +148,26 @@ class ShareUploadService : Service() {
), ),
) )
} }
if (cancelled.get()) break if (cancelled) break
val encodedName = Uri.encode(file.name) val encodedName = Uri.encode(file.name)
val url = URL("$cleanServer/remote.php/dav/files/$username$cleanFolder$encodedName") val url = URL("$cleanServer/remote.php/dav/files/${batch.username}$cleanFolder$encodedName")
val ok = uploadFile(tempFile, url, authHeader) { sent, total -> val ok = DavTransfer.put(
notify( url,
buildProgressNotification( batch.authHeader,
"Uploading $label…", tempFile,
progressFraction(sent, total), contentType = "application/octet-stream",
indeterminate = false, onProgress = { sent, total ->
), notify(
) buildProgressNotification(
} "Uploading $label…",
progressFraction(sent, total),
indeterminate = false,
),
)
},
isCancelled = { cancelled },
)
if (ok) succeeded++ else failed++ if (ok) succeeded++ else failed++
} catch (e: Exception) { } catch (e: Exception) {
failed++ failed++
@@ -167,17 +176,18 @@ class ShareUploadService : Service() {
} }
} }
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val finalText = when { val finalText = when {
cancelled.get() -> "Upload cancelled" cancelled -> "Upload cancelled"
failed == 0 && files.size == 1 -> "Uploaded ${files.first().name}" failed == 0 && batch.files.size == 1 -> "Uploaded ${batch.files.first().name}"
failed == 0 -> "Uploaded $succeeded of ${files.size} files" failed == 0 -> "Uploaded $succeeded of ${batch.files.size} files"
else -> "Uploaded $succeeded of ${files.size} files - $failed failed" 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) if (queue.pendingCount == 0) {
stopSelf() stopForeground(STOP_FOREGROUND_DETACH)
stopSelf()
}
} }
private fun progressFraction(sent: Long, total: Long?): Float? { private fun progressFraction(sent: Long, total: Long?): Float? {
@@ -193,7 +203,7 @@ class ShareUploadService : Service() {
val buffer = ByteArray(256 * 1024) val buffer = ByteArray(256 * 1024)
var sent = 0L var sent = 0L
var lastEmit = 0L var lastEmit = 0L
while (!cancelled.get()) { while (!cancelled) {
val read = input.read(buffer) val read = input.read(buffer)
if (read == -1) break if (read == -1) break
output.write(buffer, 0, read) output.write(buffer, 0, read)
@@ -209,70 +219,11 @@ class ShareUploadService : Service() {
return target return target
} }
private fun uploadFile( private fun manager() =
file: File, getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
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 notify(notification: Notification) { private fun notify(notification: Notification) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager().notify(NOTIFICATION_ID, notification)
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)
}
} }
private fun buildProgressNotification(text: String, progress: Float?, indeterminate: Boolean): Notification { private fun buildProgressNotification(text: String, progress: Float?, indeterminate: Boolean): Notification {
@@ -308,7 +259,7 @@ class ShareUploadService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
cancelled.set(true) cancelled = true
super.onDestroy() super.onDestroy()
} }
} }
@@ -0,0 +1,49 @@
package dev.ayushya.noo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
/**
* Handles the "Keep local" / "Use server" actions on a sync-conflict
* notification (see `SyncWorker.notifyConflicts`). A `BroadcastReceiver`
* can't block on network itself (~10s execution budget), so this only
* dismisses the notification and hands off to a one-shot
* [ConflictResolveWorker] for the actual upload/download.
*/
class SyncConflictReceiver : BroadcastReceiver() {
companion object {
const val ACTION_RESOLVE = "dev.ayushya.noo.action.RESOLVE_SYNC_CONFLICT"
const val EXTRA_ACCOUNT_ID = "accountId"
const val EXTRA_SERVER_URL = "serverUrl"
const val EXTRA_USERNAME = "username"
const val EXTRA_AUTH_HEADER = "authHeader"
const val EXTRA_FILE_ID = "fileId"
const val EXTRA_REMOTE_PATH = "remotePath"
const val EXTRA_REL_PATH = "relPath"
const val EXTRA_RESOLUTION = "resolution" // "local" | "server"
const val EXTRA_NOTIFICATION_ID = "notificationId"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_RESOLVE) return
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
if (notificationId != -1) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
ConflictResolveWorker.enqueue(
context,
accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID),
serverUrl = intent.getStringExtra(EXTRA_SERVER_URL),
username = intent.getStringExtra(EXTRA_USERNAME),
authHeader = intent.getStringExtra(EXTRA_AUTH_HEADER),
fileId = intent.getStringExtra(EXTRA_FILE_ID),
remotePath = intent.getStringExtra(EXTRA_REMOTE_PATH),
relPath = intent.getStringExtra(EXTRA_REL_PATH),
resolution = intent.getStringExtra(EXTRA_RESOLUTION),
)
}
}
@@ -0,0 +1,398 @@
package dev.ayushya.noo
import android.content.Context
import android.net.Uri
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
/**
* The actual WebDAV walk/diff/GET/PUT engine for device sync - shared by
* [SyncWorker] (periodic/manual full-folder sync) and
* [ConflictResolveWorker] (single-file resolution from a notification
* action), since both need the same PROPFIND/GET/PUT primitives and the
* same on-disk sync-state bookkeeping.
*
* Deliberately plain Kotlin over `HttpURLConnection`, not
* `NextcloudService`'s Dart/Dio code - this has to run from a
* [androidx.work.CoroutineWorker], independent of the Flutter engine even
* being loaded, the same reason `DownloadService.kt`/`ShareUploadService.kt`
* re-implement GET/PUT natively instead of calling back into Dart.
*/
object SyncEngine {
data class RemoteEntry(
val path: String,
val fileId: String,
val etag: String,
val lastModified: Long,
val size: Long,
val isFolder: Boolean,
)
data class FileState(
val relPath: String,
val etag: String,
val lastModified: Long,
val size: Long,
val localMTime: Long,
)
sealed class SyncAction {
data class Download(val entry: RemoteEntry) : SyncAction()
data class Upload(val relPath: String, val fileId: String) : SyncAction()
data class Delete(val relPath: String, val fileId: String) : SyncAction()
data class Conflict(val entry: RemoteEntry, val relPath: String) : SyncAction()
}
private const val TAG = "NooSync"
private const val STATE_PREFS = "noo_sync_state"
private val rfc1123 =
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
// Only used for PROPFIND - unlike HttpURLConnection, OkHttp doesn't
// reject non-standard HTTP methods.
private val httpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun syncRoot(context: Context, accountId: String): File {
val base = context.getExternalFilesDir(null) ?: context.filesDir
return File(base, "sync/$accountId").apply { mkdirs() }
}
private fun stateKey(accountId: String) = "state_$accountId"
fun loadState(context: Context, accountId: String): MutableMap<String, FileState> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
val json = prefs.getString(stateKey(accountId), null) ?: return mutableMapOf()
val obj = JSONObject(json)
val result = mutableMapOf<String, FileState>()
for (fileId in obj.keys()) {
val entry = obj.getJSONObject(fileId)
result[fileId] = FileState(
relPath = entry.getString("relPath"),
etag = entry.getString("etag"),
lastModified = entry.getLong("lastModified"),
size = entry.getLong("size"),
localMTime = entry.getLong("localMTime"),
)
}
return result
}
fun saveState(context: Context, accountId: String, state: Map<String, FileState>) {
val obj = JSONObject()
for ((fileId, s) in state) {
obj.put(
fileId,
JSONObject().apply {
put("relPath", s.relPath)
put("etag", s.etag)
put("lastModified", s.lastModified)
put("size", s.size)
put("localMTime", s.localMTime)
},
)
}
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
prefs.edit().putString(stateKey(accountId), obj.toString()).apply()
}
/** Depth-1 PROPFIND of [remotePath], returning its direct children only. */
fun propfindChildren(
serverUrl: String,
username: String,
authHeader: String,
remotePath: String,
): List<RemoteEntry> = propfind(serverUrl, username, authHeader, remotePath, depth = "1", skipSelf = true)
/** Depth-0 PROPFIND of [remotePath] itself (e.g. to refresh its etag after a PUT/GET). */
fun propfindSelf(
serverUrl: String,
username: String,
authHeader: String,
remotePath: String,
): RemoteEntry? = propfind(serverUrl, username, authHeader, remotePath, depth = "0", skipSelf = false).firstOrNull()
private fun propfind(
serverUrl: String,
username: String,
authHeader: String,
remotePath: String,
depth: String,
skipSelf: Boolean,
): List<RemoteEntry> {
var cleanPath = remotePath.trim()
if (!cleanPath.startsWith("/")) cleanPath = "/$cleanPath"
if (depth == "1" && !cleanPath.endsWith("/")) cleanPath = "$cleanPath/"
val encodedPath = cleanPath.split("/").joinToString("/") { Uri.encode(it) }
val cleanServer = serverUrl.trimEnd('/')
val url = "$cleanServer/remote.php/dav/files/$username$encodedPath"
val body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontentlength/>
<d:resourcetype/>
<d:getetag/>
<oc:fileid/>
</d:prop>
</d:propfind>"""
val request = Request.Builder()
.url(url)
.method("PROPFIND", body.toRequestBody("application/xml".toMediaType()))
.header("Authorization", authHeader)
.header("Depth", depth)
.build()
return try {
httpClient.newCall(request).execute().use { response ->
Log.d(TAG, "PROPFIND $url -> ${response.code}")
if (!response.isSuccessful) {
Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}")
return emptyList()
}
val doc = DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(response.body!!.byteStream())
parsePropfindResponse(doc, username, cleanPath, skipSelf)
}
} catch (e: Exception) {
Log.e(TAG, "PROPFIND $url threw", e)
emptyList()
}
}
private fun parsePropfindResponse(
doc: org.w3c.dom.Document,
username: String,
cleanPath: String,
skipSelf: Boolean,
): List<RemoteEntry> {
val marker = "/remote.php/dav/files/$username"
val selfPath = cleanPath.trimEnd('/')
val responses = doc.getElementsByTagNameNS("DAV:", "response")
val entries = mutableListOf<RemoteEntry>()
for (i in 0 until responses.length) {
val responseEl = responses.item(i) as? Element ?: continue
val hrefRaw = responseEl.getElementsByTagNameNS("DAV:", "href")
.item(0)?.textContent ?: continue
val decodedHref = Uri.decode(hrefRaw)
val idx = decodedHref.indexOf(marker)
val hrefPath = if (idx >= 0) decodedHref.substring(idx + marker.length) else decodedHref
val hrefPathNorm = (if (hrefPath.isEmpty()) "/" else hrefPath).trimEnd('/')
if (skipSelf && hrefPathNorm == selfPath) continue // self entry, not a child
val propEl = responseEl.getElementsByTagNameNS("DAV:", "prop").item(0) as? Element
?: continue
val resourceTypeEl =
propEl.getElementsByTagNameNS("DAV:", "resourcetype").item(0) as? Element
val isFolder =
(resourceTypeEl?.getElementsByTagNameNS("DAV:", "collection")?.length ?: 0) > 0
val etag = propEl.getElementsByTagNameNS("DAV:", "getetag")
.item(0)?.textContent?.trim('"') ?: ""
val lastModStr =
propEl.getElementsByTagNameNS("DAV:", "getlastmodified").item(0)?.textContent
val sizeStr =
propEl.getElementsByTagNameNS("DAV:", "getcontentlength").item(0)?.textContent
val fileId = propEl.getElementsByTagNameNS("http://owncloud.org/ns", "fileid")
.item(0)?.textContent ?: hrefPathNorm
entries.add(
RemoteEntry(
path = hrefPathNorm,
fileId = fileId,
etag = etag,
lastModified = lastModStr?.let { runCatching { rfc1123.parse(it)?.time }.getOrNull() } ?: 0L,
size = sizeStr?.toLongOrNull() ?: 0L,
isFolder = isFolder,
),
)
}
Log.d(TAG, "PROPFIND $cleanPath -> ${entries.size} entries")
return entries
}
/** Recursively walks [rootPath] (Depth-1 PROPFINDs, breadth-first) into a flat manifest. */
fun walkRemoteTree(
serverUrl: String,
username: String,
authHeader: String,
rootPath: String,
): List<RemoteEntry> {
val result = mutableListOf<RemoteEntry>()
val queue = ArrayDeque<String>()
queue.add(rootPath)
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
val children = propfindChildren(serverUrl, username, authHeader, current)
for (child in children) {
result.add(child)
if (child.isFolder) queue.add(child.path)
}
}
return result
}
fun downloadFile(
serverUrl: String,
username: String,
authHeader: String,
remotePath: String,
destination: File,
): Boolean {
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(
serverUrl: String,
username: String,
authHeader: String,
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) }
return URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath")
}
/**
* Diffs one synced folder's remote manifest against the persisted
* state, returning what to do for each item - callers own actually
* doing the GET/PUT/delete and updating state afterward, so this stays
* pure/testable-in-principle. Also returns entries no longer present
* remotely (deleted server-side, relative to the [priorFolderRelPaths]
* this folder previously produced) as [SyncAction.Delete].
*/
fun diffFolder(
entries: List<RemoteEntry>,
state: Map<String, FileState>,
priorFolderFileIds: Set<String>,
syncRoot: File,
): List<SyncAction> {
val actions = mutableListOf<SyncAction>()
val seenFileIds = mutableSetOf<String>()
for (entry in entries) {
if (entry.isFolder) continue
seenFileIds.add(entry.fileId)
val relPath = entry.path.removePrefix("/")
val localFile = File(syncRoot, relPath)
val prior = state[entry.fileId]
if (prior == null) {
// First time this folder's been walked with sync enabled -
// nothing locally recorded yet to protect, so just pull it.
actions.add(SyncAction.Download(entry))
continue
}
val serverChanged = entry.etag != prior.etag
val localExists = localFile.exists()
val localChanged = localExists &&
(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))
}
serverChanged && localChanged -> actions.add(SyncAction.Conflict(entry, relPath))
serverChanged -> actions.add(SyncAction.Download(entry))
localChanged -> actions.add(SyncAction.Upload(relPath, entry.fileId))
else -> {} // unchanged, nothing to do
}
}
// Anything this folder had state for last time but that didn't show
// up in this walk at all was deleted server-side.
for (fileId in priorFolderFileIds) {
if (fileId !in seenFileIds) {
val prior = state[fileId] ?: continue
actions.add(SyncAction.Delete(prior.relPath, fileId))
}
}
Log.d(
TAG,
"diffFolder: ${entries.size} entries -> " +
"${actions.count { it is SyncAction.Download }} downloads, " +
"${actions.count { it is SyncAction.Upload }} uploads, " +
"${actions.count { it is SyncAction.Delete }} deletes, " +
"${actions.count { it is SyncAction.Conflict }} conflicts",
)
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)
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) {
val normalizedFolder = folder.trimEnd('/')
if (normalizedItem == normalizedFolder || normalizedItem.startsWith("$normalizedFolder/")) {
return folder
}
}
return null
}
}
@@ -0,0 +1,80 @@
package dev.ayushya.noo
/**
* In-memory, in-process pub/sub for live device-sync status -
* [SyncWorker]/[ConflictResolveWorker] publish into this, `MainActivity`'s
* `dev.ayushya.noo/sync_service/status` EventChannel forwards it to Dart.
* Plain in-memory state is enough (no IPC/persistence needed) since the
* workers and the Activity always run in the same process; contrast with
* [SyncEngine]'s on-disk per-account state map, which *is* durable and is
* what actually answers "is this file synced" (this bus only ever tracks
* the transient "syncing right now" / "unresolved conflict" parts of that
* picture).
*/
object SyncStatusBus {
data class Conflict(
val accountId: String,
val fileId: String,
val remotePath: String,
val relPath: String,
val name: String,
)
data class Status(
val accountId: String?,
val syncing: Boolean,
val syncingFileIds: Set<String>,
val conflicts: List<Conflict>,
)
@Volatile
private var current = Status(accountId = null, syncing = false, syncingFileIds = emptySet(), conflicts = emptyList())
private val listeners = mutableListOf<(Status) -> Unit>()
@Synchronized
fun subscribe(listener: (Status) -> Unit) {
listeners.add(listener)
listener(current)
}
@Synchronized
fun unsubscribe(listener: (Status) -> Unit) {
listeners.remove(listener)
}
fun snapshot(): Status = current
@Synchronized
fun setSyncing(accountId: String, syncing: Boolean) {
current = current.copy(accountId = accountId, syncing = syncing)
if (!syncing) current = current.copy(syncingFileIds = emptySet())
publish()
}
@Synchronized
fun markFileSyncing(accountId: String, fileId: String, syncing: Boolean) {
val ids = current.syncingFileIds.toMutableSet()
if (syncing) ids.add(fileId) else ids.remove(fileId)
current = current.copy(accountId = accountId, syncingFileIds = ids)
publish()
}
@Synchronized
fun addConflicts(accountId: String, newConflicts: List<Conflict>) {
if (newConflicts.isEmpty()) return
val existingIds = current.conflicts.map { it.fileId }.toSet()
val merged = current.conflicts + newConflicts.filter { it.fileId !in existingIds }
current = current.copy(accountId = accountId, conflicts = merged)
publish()
}
@Synchronized
fun removeConflict(accountId: String, fileId: String) {
current = current.copy(accountId = accountId, conflicts = current.conflicts.filter { it.fileId != fileId })
publish()
}
private fun publish() {
listeners.toList().forEach { it(current) }
}
}
@@ -0,0 +1,269 @@
package dev.ayushya.noo
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.io.File
/**
* The periodic (and manual "Sync now") device-sync job. Runs entirely
* independent of the Flutter engine (see [SyncEngine]'s doc comment) -
* enqueued/re-enqueued from `MainActivity.kt`'s
* `dev.ayushya.noo/sync_service` MethodChannel whenever the synced-folder
* list, account, or network setting changes, since a periodic
* `WorkRequest`'s input `Data` is fixed at enqueue time.
*/
class SyncWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
companion object {
const val KEY_ACCOUNT_ID = "accountId"
const val KEY_SERVER_URL = "serverUrl"
const val KEY_USERNAME = "username"
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"
private const val CHANNEL_ID = "device_sync"
private const val SUMMARY_NOTIFICATION_ID = 4401
private const val CONFLICT_NOTIFICATION_ID_BASE = 4500
private const val TAG = "NooSync"
}
override suspend fun doWork(): 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 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()
var downloaded = 0
var uploaded = 0
var deleted = 0
val conflicts = mutableListOf<Pair<SyncEngine.RemoteEntry, String>>()
SyncStatusBus.setSyncing(accountId, true)
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
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)
}
}
}
} finally {
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) {
notifySummary(downloaded, uploaded, deleted)
}
if (conflicts.isNotEmpty()) {
SyncStatusBus.addConflicts(
accountId,
conflicts.map { (entry, relPath) ->
SyncStatusBus.Conflict(
accountId = accountId,
fileId = entry.fileId,
remotePath = entry.path,
relPath = relPath,
name = relPath.substringAfterLast('/'),
)
},
)
notifyConflicts(accountId, serverUrl, username, authHeader, conflicts)
}
Result.success()
}
private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) {
createChannel()
val parts = mutableListOf<String>()
if (downloaded > 0) parts.add("$downloaded updated")
if (uploaded > 0) parts.add("$uploaded uploaded")
if (deleted > 0) parts.add("$deleted removed")
val text = parts.joinToString(", ")
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Noo sync")
.setContentText(text)
.setAutoCancel(true)
.build()
manager().notify(SUMMARY_NOTIFICATION_ID, notification)
}
private fun notifyConflicts(
accountId: String,
serverUrl: String,
username: String,
authHeader: String,
conflicts: List<Pair<SyncEngine.RemoteEntry, String>>,
) {
createChannel()
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 useLocalIntent = conflictActionIntent(
accountId,
serverUrl,
username,
authHeader,
entry.fileId,
entry.path,
relPath,
"local",
notificationId,
)
val useServerIntent = conflictActionIntent(
accountId,
serverUrl,
username,
authHeader,
entry.fileId,
entry.path,
relPath,
"server",
notificationId,
)
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.")
.setAutoCancel(true)
.addAction(0, "Keep local", useLocalIntent)
.addAction(0, "Use server", useServerIntent)
.build()
manager().notify(notificationId, notification)
}
}
private fun conflictActionIntent(
accountId: String,
serverUrl: String,
username: String,
authHeader: String,
fileId: String,
remotePath: String,
relPath: String,
resolution: String,
notificationId: Int,
): PendingIntent {
val intent = Intent(applicationContext, SyncConflictReceiver::class.java).apply {
action = SyncConflictReceiver.ACTION_RESOLVE
putExtra(SyncConflictReceiver.EXTRA_ACCOUNT_ID, accountId)
putExtra(SyncConflictReceiver.EXTRA_SERVER_URL, serverUrl)
putExtra(SyncConflictReceiver.EXTRA_USERNAME, username)
putExtra(SyncConflictReceiver.EXTRA_AUTH_HEADER, authHeader)
putExtra(SyncConflictReceiver.EXTRA_FILE_ID, fileId)
putExtra(SyncConflictReceiver.EXTRA_REMOTE_PATH, remotePath)
putExtra(SyncConflictReceiver.EXTRA_REL_PATH, relPath)
putExtra(SyncConflictReceiver.EXTRA_RESOLUTION, resolution)
putExtra(SyncConflictReceiver.EXTRA_NOTIFICATION_ID, notificationId)
}
// Request code must be unique per (file, resolution) pair, else the
// two actions' PendingIntents collide and only one survives.
val requestCode = (relPath + resolution).hashCode()
return PendingIntent.getBroadcast(
applicationContext,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun manager() =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private fun createChannel() {
NooNotificationChannels.ensure(
applicationContext,
CHANNEL_ID,
"Device sync",
NotificationManager.IMPORTANCE_DEFAULT,
"Updates and conflicts for folders synced to this device",
)
}
}
@@ -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()
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Exposes only the app's own "picker" cache subfolder as content:// Uris -
used to hand a file back to another app that launched Noo as a
GET_CONTENT picker (see PickIntentService/MainActivity.kt). -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="picker" path="picker/" />
</paths>
+228 -59
View File
@@ -1,9 +1,23 @@
import 'dart:async'; import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/app_tab.dart'; import 'models/app_tab.dart';
import 'providers/server_provider.dart'; import 'models/pick_request.dart';
import 'providers/favorites_controller.dart';
import 'providers/files_controller.dart';
import 'providers/item_operations.dart';
import 'providers/photos_controller.dart';
import 'providers/pick_controller.dart';
import 'providers/recent_controller.dart';
import 'providers/session_controller.dart';
import 'providers/settings_controller.dart';
import 'providers/shares_controller.dart';
import 'providers/sync_status_controller.dart';
import 'providers/trash_controller.dart';
import 'services/pick_intent_service.dart';
import 'services/share_intent_service.dart'; import 'services/share_intent_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'views/lock_screen_view.dart'; import 'views/lock_screen_view.dart';
@@ -16,8 +30,51 @@ import 'widgets/floating_bottom_bar.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
runApp( runApp(
ChangeNotifierProvider( MultiProvider(
create: (_) => ServerProvider(), // Split out of the former single `ServerProvider` god object - see
// `.claude/context/architecture.md`'s "State management" section.
// Order matters: each `create` below reads earlier providers via
// `context.read`, which is safe here since none of them are ever
// replaced/recreated for the app's lifetime (account switching is
// internal state on SessionController, not a new provider instance).
providers: [
ChangeNotifierProvider(create: (_) => SessionController()),
ChangeNotifierProvider(create: (_) => SettingsController()),
ChangeNotifierProvider(
create: (context) => SyncStatusController(context.read()),
),
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
create: (context) =>
PhotosController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) =>
FavoritesController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) => TrashController(context.read()),
),
ChangeNotifierProvider(
create: (context) => SharesController(context.read()),
),
ChangeNotifierProvider(
create: (context) => RecentController(context.read()),
),
ChangeNotifierProvider(
create: (context) => PickController(context.read()),
),
Provider(
create: (context) => ItemOperations(
session: context.read(),
files: context.read(),
photos: context.read(),
favorites: context.read(),
),
),
],
child: const NextcloudApp(), child: const NextcloudApp(),
), ),
); );
@@ -28,7 +85,8 @@ class NextcloudApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final settings = context.watch<SettingsController>();
final session = context.watch<SessionController>();
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) { builder: (lightDynamic, darkDynamic) {
@@ -36,22 +94,22 @@ class NextcloudApp extends StatelessWidget {
title: 'Noo', title: 'Noo',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light( theme: AppTheme.light(
provider.seedColor, settings.seedColor,
dynamicScheme: lightDynamic, dynamicScheme: lightDynamic,
useDynamicColor: provider.useDynamicColor, useDynamicColor: settings.useDynamicColor,
), ),
darkTheme: AppTheme.dark( darkTheme: AppTheme.dark(
provider.seedColor, settings.seedColor,
dynamicScheme: darkDynamic, dynamicScheme: darkDynamic,
useDynamicColor: provider.useDynamicColor, useDynamicColor: settings.useDynamicColor,
amoled: provider.amoledDark, amoled: settings.amoledDark,
), ),
themeMode: provider.themeMode, themeMode: settings.themeMode,
home: provider.isRestoringSession home: session.isRestoringSession
? const _SplashView() ? const _SplashView()
: (!provider.isLoggedIn : (!session.isLoggedIn
? const LoginView() ? const LoginView()
: (provider.needsUnlock : (session.needsUnlock
? const LockScreenView() ? const LockScreenView()
: const MainShellView())), : const MainShellView())),
); );
@@ -113,6 +171,7 @@ class _MainShellViewState extends State<MainShellView> {
late AppTab _currentTab; late AppTab _currentTab;
late final Map<AppTab, ScrollController> _scrollControllers; late final Map<AppTab, ScrollController> _scrollControllers;
StreamSubscription<List<SharedFileRef>>? _shareSub; StreamSubscription<List<SharedFileRef>>? _shareSub;
StreamSubscription<PickRequest>? _pickSub;
@override @override
void initState() { void initState() {
@@ -123,7 +182,7 @@ class _MainShellViewState extends State<MainShellView> {
// Preferences load asynchronously, but in practice finish well before // Preferences load asynchronously, but in practice finish well before
// session restore (which also has to hit the network) does, so by the // session restore (which also has to hit the network) does, so by the
// time this shell mounts `defaultTab` already reflects the saved value. // 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 // Handles both a cold start via another app's "Share to..." sheet
// (getInitialShare) and a share arriving while the app is already // (getInitialShare) and a share arriving while the app is already
@@ -133,6 +192,64 @@ class _MainShellViewState extends State<MainShellView> {
// file's actual bytes, so a large shared file can't block startup. // file's actual bytes, so a large shared file can't block startup.
ShareIntentService.getInitialShare().then(_handleSharedFiles); ShareIntentService.getInitialShare().then(_handleSharedFiles);
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles); _shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
// Same cold-start-vs-already-running split as the share intent above:
// another app may have launched Noo as its GET_CONTENT picker.
PickIntentService.getPickRequest().then((request) {
if (request != null && mounted) {
context.read<PickController>().setPickRequest(request);
}
});
_pickSub = PickIntentService.onNewPickRequest.listen((request) {
if (mounted) context.read<PickController>().setPickRequest(request);
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => _maybeRequestNotificationPermission(),
);
}
static const _prefNotificationPermissionAsked =
'notification_permission_asked';
// Asked once, on the app's first launch after login, rather than every
// time this shell mounts - a plain-language reason shown before the OS
// prompt (which just says "Noo would like to send you notifications"
// with no context) so the system dialog doesn't feel unexplained, since
// it's what shows upload/download progress for background transfers.
Future<void> _maybeRequestNotificationPermission() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_prefNotificationPermissionAsked) ?? false) return;
await prefs.setBool(_prefNotificationPermissionAsked, true);
final status = await Permission.notification.status;
if (status.isGranted || status.isPermanentlyDenied) return;
if (!mounted) return;
final shouldRequest = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Allow notifications?'),
content: const Text(
'Noo shows upload and download progress as a notification, so '
'you can track transfers that keep running in the background.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Allow'),
),
],
),
);
if (shouldRequest == true) {
await Permission.notification.request();
}
} }
void _handleSharedFiles(List<SharedFileRef> files) { void _handleSharedFiles(List<SharedFileRef> files) {
@@ -151,25 +268,36 @@ class _MainShellViewState extends State<MainShellView> {
c.dispose(); c.dispose();
} }
_shareSub?.cancel(); _shareSub?.cancel();
_pickSub?.cancel();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>();
// A one-shot request (e.g. tapping a search result) to switch tabs - // 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 // consumed here so it only fires once, then cleared after this frame
// (clearing it synchronously would call notifyListeners mid-build). // (clearing it synchronously would call notifyListeners mid-build).
final requestedTab = provider.requestedTab; final requestedTab = settings.requestedTab;
if (requestedTab != null) { if (requestedTab != null) {
_currentTab = requestedTab; _currentTab = requestedTab;
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.consumeRequestedTab(), (_) => settings.consumeRequestedTab(),
); );
} }
final visible = provider.visibleTabs; 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.
final visible = pickRequest != null
? [AppTab.files, AppTab.photos]
: settings.visibleTabs;
final selectedTab = visible.contains(_currentTab) final selectedTab = visible.contains(_currentTab)
? _currentTab ? _currentTab
: (visible.isNotEmpty ? visible.first : AppTab.files); : (visible.isNotEmpty ? visible.first : AppTab.files);
@@ -181,50 +309,91 @@ class _MainShellViewState extends State<MainShellView> {
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon)) .map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
.toList(); .toList();
return Scaffold( return PopScope(
body: Stack( // While picking, system back cancels the pick (and tells the caller
children: [ // nothing was chosen) instead of exiting the app from under it -
IndexedStack( // there's no Navigator route to pop at this, the root screen.
index: selectedIndex, canPop: pickRequest == null,
children: visible onPopInvokedWithResult: (didPop, result) {
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) if (!didPop && pickRequest != null) pick.cancelPick();
.toList(), },
), child: Scaffold(
// Each tab now renders its own sticky selection toolbar inline body: Stack(
// (right under its sort/filter row) instead of this shell children: [
// swapping in a shared floating bar - the bottom nav stays put IndexedStack(
// and usable regardless of selection state. index: selectedIndex,
FloatingBottomNavBar( children: visible
selectedIndex: selectedIndex, .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
items: navItems, .toList(),
opacity: provider.bottomBarOpacity, ),
blurSigma: provider.bottomBarBlur, if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
onDestinationSelected: (index) { // Each tab now renders its own sticky selection toolbar inline
final tappedTab = visible[index]; // (right under its sort/filter row) instead of this shell
if (tappedTab == _currentTab) { // swapping in a shared floating bar - the bottom nav stays put
if (provider.tapTabToScrollTop) { // and usable regardless of selection state.
final controller = _scrollControllers[tappedTab]; FloatingBottomNavBar(
if (controller != null && controller.hasClients) { selectedIndex: selectedIndex,
controller.animateTo( items: navItems,
0, opacity: settings.bottomBarOpacity,
duration: const Duration(milliseconds: 300), blurSigma: settings.bottomBarBlur,
curve: Curves.easeOutCubic, onDestinationSelected: (index) {
); final tappedTab = visible[index];
if (tappedTab == _currentTab) {
if (settings.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
if (controller != null && controller.hasClients) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
} }
return;
} }
return; setState(() {
} _currentTab = tappedTab;
setState(() { });
_currentTab = tappedTab; },
}); onSearchTap: () {
}, Navigator.of(
onSearchTap: () { context,
Navigator.of( ).push(MaterialPageRoute(builder: (_) => const SearchView()));
context, },
).push(MaterialPageRoute(builder: (_) => const SearchView())); ),
}, ],
),
),
);
}
}
/// Blocks interaction while the selected file(s) download to a local cache
/// folder before being handed back to the caller (see
/// `PickController.confirmPick`) - can take a moment for a large file/video.
class _PickingProgressOverlay extends StatelessWidget {
const _PickingProgressOverlay();
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Preparing file...'),
],
),
),
), ),
], ),
), ),
); );
} }
+6 -2
View File
@@ -3,8 +3,8 @@ import 'package:material_symbols_icons/symbols.dart';
/// Every destination the bottom nav bar can show. Order here is only the /// Every destination the bottom nav bar can show. Order here is only the
/// fallback default — actual order/visibility/default-tab are user /// fallback default — actual order/visibility/default-tab are user
/// configurable and persisted in [ServerProvider]. /// configurable and persisted in `SettingsController`.
enum AppTab { files, photos, activity, trash, shares, recent } enum AppTab { files, photos, favorites, activity, trash, shares, recent }
/// At most this many tabs may be visible in the bottom nav bar at once — /// At most this many tabs may be visible in the bottom nav bar at once —
/// the rest are reachable through the "more" dropdown instead. /// the rest are reachable through the "more" dropdown instead.
@@ -17,6 +17,8 @@ extension AppTabInfo on AppTab {
return 'Files'; return 'Files';
case AppTab.photos: case AppTab.photos:
return 'Photos'; return 'Photos';
case AppTab.favorites:
return 'Favorites';
case AppTab.activity: case AppTab.activity:
return 'Activity'; return 'Activity';
case AppTab.trash: case AppTab.trash:
@@ -34,6 +36,8 @@ extension AppTabInfo on AppTab {
return Icons.folder_rounded; return Icons.folder_rounded;
case AppTab.photos: case AppTab.photos:
return Icons.photo_library_rounded; return Icons.photo_library_rounded;
case AppTab.favorites:
return Icons.favorite_rounded;
case AppTab.activity: case AppTab.activity:
return Icons.electric_bolt_rounded; return Icons.electric_bolt_rounded;
case AppTab.trash: case AppTab.trash:
+33
View File
@@ -0,0 +1,33 @@
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
/// `ItemOperations.resolveConflicts`.
enum ConflictChoice { overwrite, keepBoth, skip }
/// One item that couldn't be moved/copied because something with the same
/// name already exists at the destination (a WebDAV 412 response).
class MoveCopyConflict {
final NextcloudItem item;
const MoveCopyConflict(this.item);
}
/// Outcome of a Move/Copy batch (an initial attempt via
/// `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).
class MoveCopyResult {
final int succeeded;
final List<MoveCopyConflict> conflicts;
final int failed;
final String? blockedReason;
const MoveCopyResult({
this.succeeded = 0,
this.conflicts = const [],
this.failed = 0,
this.blockedReason,
});
}
+34
View File
@@ -0,0 +1,34 @@
/// Another app asking Noo (via Android's GET_CONTENT chooser) to pick a
/// file/photo and hand it back - see `PickIntentService`/`MainActivity.kt`.
class PickRequest {
final String mimeType;
final bool allowMultiple;
final String? callerLabel;
const PickRequest({
required this.mimeType,
required this.allowMultiple,
this.callerLabel,
});
factory PickRequest.fromMap(Map<dynamic, dynamic> map) {
return PickRequest(
mimeType: map['mimeType'] as String? ?? '*/*',
allowMultiple: map['allowMultiple'] as bool? ?? false,
callerLabel: map['callerLabel'] as String?,
);
}
/// Whether an item's mime type satisfies this request's filter (which may
/// be a wildcard like `image/*` or the catch-all `*/*`).
bool matches(String? itemMimeType) {
if (mimeType == '*/*') return true;
if (itemMimeType == null) return false;
if (mimeType.endsWith('/*')) {
return itemMimeType.startsWith(
mimeType.substring(0, mimeType.length - 1),
);
}
return itemMimeType == mimeType;
}
}
-2
View File
@@ -6,13 +6,11 @@ import 'package:flutter/material.dart';
class SelectionAction { class SelectionAction {
final IconData icon; final IconData icon;
final String label; final String label;
final Color? color;
final VoidCallback onTap; final VoidCallback onTap;
const SelectionAction({ const SelectionAction({
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
this.color,
}); });
} }
+38
View File
@@ -0,0 +1,38 @@
/// A pending device-sync conflict (a file changed both locally and on the
/// server since the last sync) - mirrors `SyncStatusBus.Conflict` on the
/// native side exactly, see `SyncEngine.kt`/`server.md`'s "Device sync"
/// section.
class SyncConflictInfo {
final String accountId;
final String fileId;
final String remotePath;
final String relPath;
final String name;
const SyncConflictInfo({
required this.accountId,
required this.fileId,
required this.remotePath,
required this.relPath,
required this.name,
});
factory SyncConflictInfo.fromMap(Map<dynamic, dynamic> map) {
return SyncConflictInfo(
accountId: map['accountId'] as String? ?? '',
fileId: map['fileId'] as String? ?? '',
remotePath: map['remotePath'] as String? ?? '',
relPath: map['relPath'] as String? ?? '',
name: map['name'] as String? ?? '',
);
}
}
/// A single item's device-sync status, used to badge its tile in the
/// Files view.
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
/// `SyncStatusController.syncHeaderStatus`.
enum SyncHeaderStatus { off, syncing, done, alert }
+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();
}
}
+557
View File
@@ -0,0 +1,557 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/nextcloud_item.dart';
import 'session_controller.dart';
enum FileSortField { name, dateCreated, dateModified, size }
/// Which storage a listing shows — always exactly one, like the list/grid
/// view toggle, not an optional filter. Shared across Files/Photos/
/// Favorites (see [applyCommonFilters]'s doc comment).
enum StorageScope { cloud, external }
/// The Files tab's files/folders/both filter — independent of and applied
/// after [StorageScope]/favorites/hidden filtering.
enum FilesTypeFilter { all, filesOnly, foldersOnly }
/// How aggressively the Files tab's folder listings (not thumbnails/file
/// content - just the list of names/sizes/dates) are reused across
/// navigation instead of refetched from the server every time.
enum CachePolicy {
/// Every navigation refetches - today's behavior.
never,
/// A listing is reused until it's older than [FilesController.
/// cacheIntervalMinutes]; a timer also proactively refreshes the current
/// folder on that same interval while the app is in the foreground.
interval,
/// A listing is reused indefinitely until the user pulls to refresh.
manual,
}
/// One cached folder listing and when it was fetched.
class _CachedDirectory {
final List<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 {
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 = CachePolicy.never;
int _cacheIntervalMinutes = 5;
final Map<String, _CachedDirectory> _directoryCache = {};
Timer? _cacheRefreshTimer;
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);
}
// Getters
String get currentFolderPath => _currentFolderPath;
List<String> get pathStack => _pathStack;
bool get isGridView => _isGridView;
StorageScope get storageScope => _storageScope;
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
bool get showHiddenFiles => _showHiddenFiles;
FileSortField get filesSortField =>
_folderSortField[_currentFolderPath] ?? FileSortField.name;
bool get filesSortAscending =>
_folderSortAscending[_currentFolderPath] ?? true;
CachePolicy get cachePolicy => _cachePolicy;
int get cacheIntervalMinutes => _cacheIntervalMinutes;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
NextcloudUserQuota? get quota => _quota;
List<NextcloudActivity> get activities => _activities;
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,
}) {
var filtered = source;
if (showFavoritesOnly) {
filtered = filtered.where((item) => item.isFavorite).toList();
}
if (!showHidden) {
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
}
filtered = filtered
.where(
(item) => _storageScope == StorageScope.external
? item.isExternalStorage
: !item.isExternalStorage,
)
.toList();
return filtered;
}
/// Applies the Files tab's current sort/filter display prefs (hidden,
/// type filter, storage scope, sort field/direction) to an arbitrary raw
/// item list - factored out of the [items] getter so both the Favorites
/// tab and the Move/Copy destination picker (which fetches its own
/// listings via [fetchFolderListing] rather than reading [items] itself)
/// can render with the exact same controls/behavior as the Files tab
/// without duplicating this logic.
List<NextcloudItem> applyFilesDisplayPrefs(List<NextcloudItem> rawItems) {
var filtered = applyCommonFilters(
rawItems,
// Files itself doesn't filter by favorite - that's the dedicated
// Favorites tab's job.
showFavoritesOnly: false,
showHidden: _showHiddenFiles,
);
switch (_filesTypeFilter) {
case FilesTypeFilter.all:
break;
case FilesTypeFilter.filesOnly:
filtered = filtered.where((i) => !i.isFolder).toList();
case FilesTypeFilter.foldersOnly:
filtered = filtered.where((i) => i.isFolder).toList();
}
final field = filesSortField;
final folders = filtered.where((i) => i.isFolder).toList()
..sort((a, b) => _compareItems(a, b, field));
final files = filtered.where((i) => !i.isFolder).toList()
..sort((a, b) => _compareItems(a, b, field));
return filesSortAscending
? [...folders, ...files]
: [...folders.reversed, ...files.reversed];
}
void _onAccountCleared() {
_cacheRefreshTimer?.cancel();
_directoryCache.clear();
_folderSortField.clear();
_folderSortAscending.clear();
_items = [];
_quota = null;
_activities = [];
_currentFolderPath = '/';
_pathStack = ['/'];
_isGridView = false;
_storageScope = StorageScope.cloud;
_filesTypeFilter = FilesTypeFilter.all;
_showHiddenFiles = false;
_cachePolicy = CachePolicy.never;
_cacheIntervalMinutes = 5;
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
Future<void> _onAccountActivated() async {
final id = session.activeAccountId;
if (id != null) {
final prefs = await session.prefsFuture;
String k(String base) => session.accountStore.accountPrefKey(id, base);
_isGridView = prefs.getBool(k(_prefGridView)) ?? false;
final storageScopeName = prefs.getString(k(_prefStorageScope));
_storageScope = StorageScope.values.firstWhere(
(s) => s.name == storageScopeName,
orElse: () => StorageScope.cloud,
);
final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter));
_filesTypeFilter = FilesTypeFilter.values.firstWhere(
(f) => f.name == filesTypeFilterName,
orElse: () => FilesTypeFilter.all,
);
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
final folderSortJson = prefs.getString(k(_prefFolderSort));
if (folderSortJson != null) {
try {
final decoded = jsonDecode(folderSortJson) as Map<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: () => CachePolicy.never,
);
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
notifyListeners();
}
_startCacheRefreshTimerIfNeeded();
await refreshData();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Only relevant under CachePolicy.interval.
if (state == AppLifecycleState.resumed) {
_startCacheRefreshTimerIfNeeded();
} else if (state == AppLifecycleState.paused) {
_cacheRefreshTimer?.cancel();
}
}
void _startCacheRefreshTimerIfNeeded() {
_cacheRefreshTimer?.cancel();
if (_cachePolicy != CachePolicy.interval || !session.isLoggedIn) return;
_cacheRefreshTimer = Timer.periodic(
Duration(minutes: _cacheIntervalMinutes),
(_) => refreshData(),
);
}
bool _isCacheFresh(String path) {
if (_cachePolicy == CachePolicy.never) return false;
final cached = _directoryCache[path];
if (cached == null) return false;
if (_cachePolicy == CachePolicy.manual) return true;
return DateTime.now().difference(cached.fetchedAt) <
Duration(minutes: _cacheIntervalMinutes);
}
Future<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;
_isLoading = true;
_errorMessage = null;
notifyListeners();
debugPrint('[FilesController] Refreshing data for path: $_currentFolderPath');
try {
final items = await service.fetchDirectory(_currentFolderPath);
if (gen != session.sessionGeneration) return;
_items = items;
_directoryCache[_currentFolderPath] = _CachedDirectory(
_items,
DateTime.now(),
);
debugPrint(
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
);
try {
_quota = await service.fetchUserQuota();
} catch (e) {
debugPrint('[FilesController] Quota fetch warning: $e');
}
try {
_activities = await service.fetchActivities();
} catch (e) {
debugPrint('[FilesController] Activity fetch warning: $e');
}
} catch (e) {
if (gen != session.sessionGeneration) return;
debugPrint('[FilesController] Error fetching directory: $e');
_errorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
if (gen == session.sessionGeneration) {
_isLoading = false;
notifyListeners();
}
}
}
Future<List<NextcloudItem>> searchFiles(String query) async {
final service = session.service;
if (service == null) return [];
return service.searchFiles(query);
}
Future<void> navigateToFolder(String path) async {
_pathStack.add(path);
await _navigateTo(path);
}
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.
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;
}
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) {
if (filesSortField == field) return;
_folderSortField[_currentFolderPath] = field;
notifyListeners();
_persistFolderSort();
}
void toggleFilesSortOrder() {
_folderSortAscending[_currentFolderPath] = !filesSortAscending;
notifyListeners();
_persistFolderSort();
}
void setCachePolicy(CachePolicy policy) {
if (_cachePolicy == policy) return;
_cachePolicy = policy;
notifyListeners();
_persistAccountPref(
_prefCachePolicy,
(p, key) => p.setString(key, policy.name),
);
_startCacheRefreshTimerIfNeeded();
}
void setCacheIntervalMinutes(int minutes) {
final clamped = minutes.clamp(1, 60);
if (_cacheIntervalMinutes == clamped) return;
_cacheIntervalMinutes = clamped;
notifyListeners();
_persistAccountPref(
_prefCacheIntervalMinutes,
(p, key) => p.setInt(key, clamped),
);
_startCacheRefreshTimerIfNeeded();
}
Future<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();
}
}
+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;
}
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/nextcloud_item.dart';
import 'files_controller.dart';
import 'session_controller.dart';
/// The Photos tab: every image/video across the whole account (not just
/// the currently browsed folder - see [fetchAllMedia]). Depends on
/// [FilesController] only for the shared [StorageScope] toggle and
/// [FilesController.applyCommonFilters] - Photos' own favorites-only/
/// hidden toggles are independent of Files', but the storage-scope split
/// (cloud vs external) is one shared setting across every tab.
class PhotosController extends ChangeNotifier {
final SessionController session;
final FilesController files;
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
static const _prefSortField = 'ui_sort_field';
static const _prefSortAscending = 'ui_sort_ascending';
List<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) {
final prefs = await session.prefsFuture;
String k(String base) => session.accountStore.accountPrefKey(id, base);
_showFavoritesOnly =
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
final sortFieldName = prefs.getString(k(_prefSortField));
_sortField = FileSortField.values.firstWhere(
(f) => f.name == sortFieldName,
orElse: () => FileSortField.name,
);
_sortAscending = prefs.getBool(k(_prefSortAscending)) ?? true;
notifyListeners();
}
unawaited(fetchAllMedia());
}
/// Loads every image/video across the whole account for the Photos tab.
Future<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
+571
View File
@@ -0,0 +1,571 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/saved_account.dart';
import '../services/account_store.dart';
import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart';
import '../services/sync_service.dart';
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
/// Owns everything about *which* account is live and whether it's actually
/// authenticated: the saved-accounts list, the active account, Login Flow
/// v2, the live [NextcloudService] instance, the session-generation guard
/// every other controller's fetches check against, and app lock. Doesn't
/// hold a single byte of any tab's *content* - that's what
/// [FilesController]/`PhotosController`/etc. own, each reacting to this
/// controller's account-activated/cleared signals (below) rather than this
/// controller reaching into them directly, since this is constructed
/// before them (they depend on it, not the other way around).
///
/// Split out of the former single `ServerProvider` god object - see
/// `.claude/context/architecture.md`'s "State management" section for the
/// full rationale.
class SessionController extends ChangeNotifier with WidgetsBindingObserver {
final Future<SharedPreferences> prefsFuture =
SharedPreferences.getInstance();
final AccountStore accountStore = AccountStore();
String _serverUrl = '';
String _username = '';
String _password = '';
bool _isLoggedIn = false;
bool _isLoading = false;
bool _isRestoringSession = true;
String? _errorMessage;
LoginFlowStatus _loginFlowStatus = LoginFlowStatus.idle;
Uri? _pendingLoginUrl;
Timer? _pollTimer;
Timer? _pollTimeoutTimer;
bool _addAccountFlowActive = false;
NextcloudService? _service;
List<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 (fetch your own data now).
final List<VoidCallback> _accountClearedListeners = [];
final List<VoidCallback> _accountActivatedListeners = [];
void addAccountClearedListener(VoidCallback cb) =>
_accountClearedListeners.add(cb);
void addAccountActivatedListener(VoidCallback cb) =>
_accountActivatedListeners.add(cb);
void _notifyAccountCleared() {
for (final cb in _accountClearedListeners) {
cb();
}
}
void _notifyAccountActivated() {
for (final cb in _accountActivatedListeners) {
cb();
}
}
SessionController() {
WidgetsBinding.instance.addObserver(this);
_init();
}
Future<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. The password is expected to already
/// be durably saved by the caller (either freshly, via
/// [_completeLoginFlow], or previously, since this is also how a saved
/// session is restored/switched to) - this method only writes to
/// [AccountStore] to drop a password that turns out to no longer work.
Future<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,
);
try {
final success = await _service!.testConnection();
if (gen != _sessionGeneration) return false;
if (success) {
_isLoggedIn = true;
_isLoading = false;
notifyListeners();
_notifyAccountActivated();
return true;
}
} catch (e) {
if (gen != _sessionGeneration) return false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
// Only drop the stored password on an actual auth rejection (401) -
// testConnection also throws for network-level failures (DNS,
// timeout, unreachable host), and those are transient: deleting a
// still-valid password on a dropped connection would permanently
// log the account out with no way back in short of Login Flow v2
// again, since activeAccountId is never cleared and switchAccount()
// no-ops when asked to "switch" to the account that's already
// (nominally) active.
if (_errorMessage?.contains('401') == true) {
await accountStore.deletePassword(account.id);
}
}
if (gen != _sessionGeneration) return false;
_isLoggedIn = false;
_service = null;
_isLoading = false;
notifyListeners();
return false;
}
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
/// URL, then polls until the user authorizes (in LoginWebViewView, pushed
/// by LoginView once loginFlowStatus flips to awaitingBrowser below) and
/// the server hands back a scoped app password. The app never sees the
/// user's real password. [addAccount] only marks the flow as "add
/// another account" for [isAddAccountFlow] - the pushed Add Account
/// screen reads that to decide when to pop itself; this method's own
/// persistence behavior on success ([_completeLoginFlow]) is the same
/// either way (create-or-refresh the resulting account, then activate
/// it).
Future<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);
notifyListeners();
final account = _accounts.where((a) => a.id == accountId).firstOrNull;
final password = account == null
? null
: await accountStore.readPassword(accountId);
if (account == null || password == null) {
_isLoading = false;
_isLoggedIn = false;
notifyListeners();
return;
}
await _applyCredentialsForAccount(account, password);
}
/// Switches to an already-saved account. No-op (returns true) if it's
/// already active and logged in; no-op (returns false) if unknown - but
/// if [accountId] is nominally "active" while [isLoggedIn] is false (a
/// session-restore that failed, e.g. transient network trouble at cold
/// start), this still retries rather than no-op, since that's exactly the
/// case LoginView's "Continue as" tile exists to recover from. Gated
/// behind login lock when [lockAccountSwitching] is on. Returns whether
/// the account ended up logged in, so callers (LoginView's saved-account
/// tile) can surface a failure - e.g. a stored app password that no
/// longer works and needs the account removed/re-added.
Future<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));
}
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();
_isLoggedIn = false;
_serverUrl = '';
_username = '';
_password = '';
_activeAccountId = null;
final prefs = await prefsFuture;
await accountStore.saveActiveAccountId(prefs, null);
notifyListeners();
unawaited(SyncService.cancel());
}
/// Ends the active session but keeps this account saved - unlike
/// [removeAccount], nothing is deleted (password, prefs, its entry in
/// [accounts] all remain), so it's available to resume with a single tap
/// from the login screen's saved-accounts list, no Login Flow v2 needed.
/// Always lands on the login screen even if other accounts are saved -
/// deliberately not the same as switching to one of them.
Future<void> logout() async {
if (_activeAccountId == null) return;
await _deactivateSession();
}
/// Confirms the device can do local auth and prompts once to enable login
/// lock. Returns false (leaving lock disabled) if the device has no
/// biometric/PIN capability or the user cancels/fails the confirmation.
Future<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);
_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;
}
}
+283
View File
@@ -0,0 +1,283 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import '../models/sync_status.dart';
import '../services/sync_service.dart';
import 'session_controller.dart';
/// Device-sync state and settings: which remote paths (files or folders,
/// per account) get mirrored locally by the native `SyncWorker`, whether
/// its periodic background runs are allowed on cellular, and live status
/// pushed from `SyncStatusBus.kt` (syncing-now/per-file-syncing/synced/
/// conflicts) - see `server.md`'s "Device sync" section. Split out of the
/// former single `ServerProvider` god object specifically because this
/// state used to reach directly into Files/Photos/Favorites item lists
/// (`syncStatusFor`) despite living in an unrelated domain; now it's a
/// second, focused provider those views read alongside their own tab
/// controller.
class SyncStatusController extends ChangeNotifier {
final SessionController session;
static const _prefSyncedPaths = 'ui_synced_folders';
static const _prefSyncedPathTypes = 'ui_synced_folder_types';
static const _prefSyncEverything = 'ui_sync_everything';
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
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 _isSyncingNow = false;
Set<String> _syncingFileIds = {};
Set<String> _syncedFileIds = {};
List<SyncConflictInfo> _syncConflicts = [];
StreamSubscription<SyncStatusSnapshot>? _statusSub;
SyncStatusController(this.session) {
session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated);
_statusSub = SyncService.statusStream.listen(_applySnapshot);
_loadGlobalPrefs();
}
List<String> get syncedPaths => List.unmodifiable(_syncedPaths);
bool get syncEverything => _syncEverything;
bool get syncOnCellular => _syncOnCellular;
bool get isSyncingNow => _isSyncingNow;
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;
/// How many individual files have actually been mirrored locally so far
/// - distinct from [syncedFolderCount]/[syncedPaths], which are just the
/// configured *targets*, not what's actually landed on disk yet.
int get syncedItemCount => _syncedFileIds.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;
notifyListeners();
}
void _onAccountCleared() {
_syncedPaths = [];
_syncedPathTypes = {};
_syncEverything = false;
_isSyncingNow = false;
_syncingFileIds = {};
_syncedFileIds = {};
_syncConflicts = [];
notifyListeners();
}
Future<void> _onAccountActivated() async {
final id = session.activeAccountId;
if (id != null) {
final prefs = await session.prefsFuture;
String k(String base) => session.accountStore.accountPrefKey(id, base);
final pathsJson = prefs.getString(k(_prefSyncedPaths));
if (pathsJson != null) {
try {
_syncedPaths = (jsonDecode(pathsJson) as List).cast<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();
}
unawaited(SyncService.reschedule(session, this));
unawaited(SyncService.getStatus().then(_applySnapshot));
}
void _applySnapshot(SyncStatusSnapshot snapshot) {
if (snapshot.accountId != null &&
snapshot.accountId != session.activeAccountId) {
return;
}
_isSyncingNow = snapshot.syncing;
_syncingFileIds = snapshot.syncingFileIds;
_syncedFileIds = snapshot.syncedFileIds;
_syncConflicts = snapshot.conflicts;
notifyListeners();
}
void _persistSyncedPaths() {
final id = session.activeAccountId;
if (id == null) return;
session.prefsFuture.then((p) {
p.setString(
session.accountStore.accountPrefKey(id, _prefSyncedPaths),
jsonEncode(_syncedPaths),
);
p.setString(
session.accountStore.accountPrefKey(id, _prefSyncedPathTypes),
jsonEncode(_syncedPathTypes),
);
});
}
bool isPathSynced(String path) => _syncedPaths.contains(path);
void addSyncedPath(String path, {required bool isFolder}) {
if (_syncedPaths.contains(path)) return;
_syncedPaths = [..._syncedPaths, path];
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(session, this));
}
/// 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.
void removeSyncedPath(String path) {
if (!_syncedPaths.contains(path)) return;
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(session, this));
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));
}
void setSyncOnCellular(bool value) {
if (_syncOnCellular == value) return;
_syncOnCellular = value;
notifyListeners();
session.prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value));
unawaited(SyncService.reschedule(session, this));
}
/// In-app conflict resolution (the sync header's "Keep local"/"Use
/// server" buttons) - see `SyncService.resolveConflict`'s doc comment
/// for why this shares the exact same native path the notification
/// actions use.
Future<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() {
_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;
}
}
+2 -1
View File
@@ -27,7 +27,6 @@ class AccountStore {
/// by account switching. /// by account switching.
static const perAccountPrefKeys = [ static const perAccountPrefKeys = [
'ui_grid_view', 'ui_grid_view',
'ui_show_favorites_only',
'ui_show_favorites_only_photos', 'ui_show_favorites_only_photos',
'ui_storage_scope', 'ui_storage_scope',
'ui_show_hidden', 'ui_show_hidden',
@@ -38,6 +37,8 @@ class AccountStore {
'ui_files_type_filter', 'ui_files_type_filter',
'ui_cache_policy', 'ui_cache_policy',
'ui_cache_interval_minutes', 'ui_cache_interval_minutes',
'ui_synced_folders',
'ui_sync_everything',
]; ];
String accountPrefKey(String accountId, String baseKey) => String accountPrefKey(String accountId, String baseKey) =>
+43
View File
@@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/nextcloud_item.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`/
/// `ShareUploadService.kt` (see that pair's doc comments for why this
/// isn't done in Dart/Dio: only a real Android Service survives the
/// Flutter engine/Activity being gone, the same guarantee a real
/// file-manager app's download notification gives you). Saves straight
/// into the device's public Downloads folder with one cancellable
/// progress notification for the whole batch, instead of the old
/// download-to-app-cache-then-`file_saver`-prompt flow.
class DownloadService {
static const _channel = MethodChannel('dev.ayushya.noo/download_service');
/// Starts downloading [items]. Fire-and-forget from Dart's perspective -
/// once this returns, the service owns the rest and reports
/// progress/completion/cancellation through its own notification, not
/// back to the app.
static Future<void> startDownload(
SessionController session,
List<NextcloudItem> items,
) async {
final args = baseChannelArgs(session);
final filesJson = jsonEncode(
items
.map(
(i) => {
'path': i.path,
'name': i.name,
'mimeType': i.mimeType,
'size': i.size,
},
)
.toList(),
);
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),
};
}
+269 -51
View File
@@ -699,6 +699,170 @@ class NextcloudService {
return results; return results;
} }
/// Finds every favorited file/folder anywhere in the account (not just
/// the currently browsed folder) via the same WebDAV SEARCH-REPORT
/// mechanism as [fetchAllMedia], filtered by `oc:favorite` instead of
/// mimetype. Used by the Files tab's favorites-only toggle: filtering
/// should still be strict (only favorited items), but the *scope* is the
/// whole account, not whatever folder happens to be open - otherwise a
/// favorited item nested a few folders down would never show up just
/// because its parent folders aren't favorited themselves.
Future<List<NextcloudItem>> fetchFavorites() async {
final url = '$_cleanServerUrl/remote.php/dav/';
debugPrint('[Nextcloud DAV] SEARCH for favorites from $url');
final body =
'''<?xml version="1.0" encoding="utf-8" ?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:creationdate/>
<d:getcontenttype/>
<d:resourcetype/>
<oc:favorite/>
<oc:fileid/>
<nc:mount-type/>
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>/files/$username</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
<d:eq>
<d:prop><oc:favorite/></d:prop>
<d:literal>1</d:literal>
</d:eq>
</d:where>
<d:orderby>
<d:order>
<d:prop><d:displayname/></d:prop>
<d:ascending/>
</d:order>
</d:orderby>
</d:basicsearch>
</d:searchrequest>''';
final request = http.Request('SEARCH', Uri.parse(url))
..headers.addAll({..._headers, 'Content-Type': 'text/xml'})
..body = body;
final streamed = await http.Client().send(request);
final responseBody = await streamed.stream.bytesToString();
debugPrint(
'[Nextcloud DAV] Favorites SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
);
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
throw Exception('Failed to load favorites. HTTP ${streamed.statusCode}');
}
final document = xml.XmlDocument.parse(responseBody);
final responses = document.findLocalDescendants('response');
final results = <NextcloudItem>[];
for (var res in responses) {
final hrefNode =
res.findLocalChildren('href').firstOrNull ??
res.findLocalDescendants('href').firstOrNull;
final href = hrefNode?.innerText ?? '';
if (href.isEmpty) continue;
final props = res.findLocalDescendants('prop');
if (props.isEmpty) continue;
bool isCollection = false;
String? sizeStr;
String? lastModStr;
String? createdStr;
String? mimeType;
String? fileId;
String? mountType;
for (var prop in props) {
if (!isCollection) {
final resTypeNode = prop
.findLocalChildren('resourcetype')
.firstOrNull;
if (resTypeNode != null &&
resTypeNode.findLocalChildren('collection').isNotEmpty) {
isCollection = true;
}
}
sizeStr ??= prop
.findLocalChildren('getcontentlength')
.firstOrNull
?.innerText;
lastModStr ??= prop
.findLocalChildren('getlastmodified')
.firstOrNull
?.innerText;
createdStr ??= prop
.findLocalChildren('creationdate')
.firstOrNull
?.innerText;
mimeType ??= prop
.findLocalChildren('getcontenttype')
.firstOrNull
?.innerText;
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
mountType ??= prop
.findLocalChildren('mount-type')
.firstOrNull
?.innerText;
}
var decodedHref = Uri.decodeFull(href);
if (decodedHref.endsWith('/') && decodedHref.length > 1) {
decodedHref = decodedHref.substring(0, decodedHref.length - 1);
}
final name = decodedHref.split('/').where((s) => s.isNotEmpty).last;
if (name.isEmpty) continue;
// Strip the DAV root prefix so `path` matches what fetchDirectory produces.
final marker = '/files/$username';
final markerIndex = decodedHref.indexOf(marker);
final itemPath = markerIndex >= 0
? decodedHref.substring(markerIndex + marker.length)
: decodedHref;
if (itemPath.isEmpty) continue;
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
results.add(
NextcloudItem(
id: validId,
name: name,
path: itemPath,
type: itemType,
size: size,
lastModified: lastMod,
dateCreated: created,
isFavorite: true,
mimeType: mimeType,
previewUrl:
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
mountType: mountType,
),
);
}
debugPrint(
'[Nextcloud DAV] Favorites SEARCH found ${results.length} items',
);
return results;
}
/// Finds recently modified files across the whole account (folders /// Finds recently modified files across the whole account (folders
/// excluded), newest first — same SEARCH-REPORT mechanism as /// excluded), newest first — same SEARCH-REPORT mechanism as
/// [fetchAllMedia], just without the image/video mimetype filter. /// [fetchAllMedia], just without the image/video mimetype filter.
@@ -939,44 +1103,6 @@ class NextcloudService {
return []; return [];
} }
/// Uploads a file from disk, streaming it so large files don't need to be
/// buffered fully in memory, with progress reporting.
Future<bool> uploadFileFromPath(
String folderPath,
String fileName,
String localFilePath, {
void Function(int sent, int total)? onProgress,
}) async {
var cleanPath = folderPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/';
final url =
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName';
debugPrint('[Nextcloud DAV] Streaming upload to $url');
final file = File(localFilePath);
final length = await file.length();
final dio = Dio();
final response = await dio.put<void>(
url,
data: file.openRead(),
options: Options(
headers: {..._headers, Headers.contentLengthHeader: length},
contentType: 'application/octet-stream',
),
onSendProgress: onProgress,
);
debugPrint(
'[Nextcloud DAV] Streaming upload status: ${response.statusCode}',
);
return response.statusCode == 201 ||
response.statusCode == 204 ||
response.statusCode == 200;
}
Future<bool> deleteItem(String itemPath) async { Future<bool> deleteItem(String itemPath) async {
var cleanPath = itemPath.trim(); var cleanPath = itemPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
@@ -989,6 +1115,43 @@ class NextcloudService {
return response.statusCode == 204 || response.statusCode == 200; return response.statusCode == 204 || response.statusCode == 200;
} }
/// Shared MOVE/COPY request - WebDAV COPY is the same shape as the MOVE
/// [renameItem] uses, just a different verb and a caller-controlled
/// `Overwrite` rather than always forced off. Returns the raw HTTP status
/// rather than a bool so callers can tell a conflict (412 - only
/// possible when `overwrite` is false) apart from any other failure
/// without a separate existence-check request per item.
Future<int> _moveOrCopy(
String sourcePath,
String destPath, {
required bool copy,
required bool overwrite,
}) async {
var cleanSource = sourcePath.trim();
if (!cleanSource.startsWith('/')) cleanSource = '/$cleanSource';
var cleanDest = destPath.trim();
if (!cleanDest.startsWith('/')) cleanDest = '/$cleanDest';
final sourceUrl =
'$_cleanServerUrl/remote.php/dav/files/$username$cleanSource';
final destUrl = '$_cleanServerUrl/remote.php/dav/files/$username$cleanDest';
debugPrint(
'[Nextcloud DAV] ${copy ? 'Copying' : 'Moving'} $sourceUrl -> $destUrl',
);
final request = http.Request(copy ? 'COPY' : 'MOVE', Uri.parse(sourceUrl))
..headers.addAll({
..._headers,
'Destination': Uri.encodeFull(destUrl),
'Overwrite': overwrite ? 'T' : 'F',
});
final response = await http.Client().send(request);
debugPrint(
'[Nextcloud DAV] ${copy ? 'Copy' : 'Move'} status: ${response.statusCode}',
);
return response.statusCode;
}
/// Renames (or moves within the same folder) an item via WebDAV MOVE. /// Renames (or moves within the same folder) an item via WebDAV MOVE.
Future<bool> renameItem(String itemPath, String newName) async { Future<bool> renameItem(String itemPath, String newName) async {
var cleanPath = itemPath.trim(); var cleanPath = itemPath.trim();
@@ -997,20 +1160,75 @@ class NextcloudService {
final segments = cleanPath.split('/')..removeLast(); final segments = cleanPath.split('/')..removeLast();
final destPath = '${segments.join('/')}/$newName'; final destPath = '${segments.join('/')}/$newName';
final sourceUrl = final status = await _moveOrCopy(
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; cleanPath,
final destUrl = '$_cleanServerUrl/remote.php/dav/files/$username$destPath'; destPath,
debugPrint('[Nextcloud DAV] Renaming $sourceUrl -> $destUrl'); copy: false,
overwrite: false,
);
return status == 201 || status == 204;
}
final request = http.Request('MOVE', Uri.parse(sourceUrl)) Future<int> _moveOrCopyToFolder(
..headers.addAll({ String itemPath,
..._headers, String destFolderPath, {
'Destination': Uri.encodeFull(destUrl), required bool copy,
'Overwrite': 'F', required bool overwrite,
}); String? newName,
final response = await http.Client().send(request); }) {
debugPrint('[Nextcloud DAV] Rename status: ${response.statusCode}'); var cleanPath = itemPath.trim();
return response.statusCode == 201 || response.statusCode == 204; if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
final name = newName ?? cleanPath.split('/').last;
var cleanDestFolder = destFolderPath.trim();
if (!cleanDestFolder.startsWith('/')) cleanDestFolder = '/$cleanDestFolder';
if (cleanDestFolder.endsWith('/') && cleanDestFolder != '/') {
cleanDestFolder = cleanDestFolder.substring(
0,
cleanDestFolder.length - 1,
);
}
final destPath = cleanDestFolder == '/'
? '/$name'
: '$cleanDestFolder/$name';
return _moveOrCopy(cleanPath, destPath, copy: copy, overwrite: overwrite);
}
/// Moves [itemPath] into [destFolderPath], keeping its current filename
/// unless [newName] is given (used to resolve a "keep both" conflict
/// with a renamed destination). Returns the raw HTTP status - 201/204
/// success, 412 means an item with the same name already exists there
/// ([overwrite] was false).
Future<int> moveItem(
String itemPath,
String destFolderPath, {
bool overwrite = false,
String? newName,
}) {
return _moveOrCopyToFolder(
itemPath,
destFolderPath,
copy: false,
overwrite: overwrite,
newName: newName,
);
}
/// Same as [moveItem] but duplicates the item instead of relocating it -
/// WebDAV COPY, recursive for folders by default just like MOVE.
Future<int> copyItem(
String itemPath,
String destFolderPath, {
bool overwrite = false,
String? newName,
}) {
return _moveOrCopyToFolder(
itemPath,
destFolderPath,
copy: true,
overwrite: overwrite,
newName: newName,
);
} }
Future<bool> createFolder(String parentPath, String folderName) async { Future<bool> createFolder(String parentPath, String folderName) async {
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/services.dart';
import '../models/pick_request.dart';
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
/// stream for an intent arriving while the app is already running - the
/// same `singleTask` launch mode applies here).
class PickIntentService {
static const _methodChannel = MethodChannel('dev.ayushya.noo/pick_intent');
static const _newPickChannel = EventChannel(
'dev.ayushya.noo/pick_intent/new',
);
/// Non-null if the app was launched cold as another app's file/photo
/// picker.
static Future<PickRequest?> getPickRequest() async {
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
'getPickRequest',
);
return result == null ? null : PickRequest.fromMap(result);
}
/// Emits whenever the app is asked to act as a picker while already
/// running.
static Stream<PickRequest> get onNewPickRequest {
return _newPickChannel.receiveBroadcastStream().map(
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
);
}
/// Hands the already-downloaded local files back to the caller and closes
/// the picker. [mimeTypes] is parallel to [localPaths].
static Future<void> finishPick(
List<String> localPaths,
List<String> mimeTypes,
) {
return _methodChannel.invokeMethod('finishPick', {
'paths': localPaths,
'mimeTypes': mimeTypes,
});
}
/// Backs out of picking mode with no result, closing the picker.
static Future<void> cancelPick() {
return _methodChannel.invokeMethod('cancelPick');
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/sync_status.dart';
import '../providers/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`.
class SyncStatusSnapshot {
final String? accountId;
final bool syncing;
final Set<String> syncingFileIds;
final Set<String> syncedFileIds;
final List<SyncConflictInfo> conflicts;
const SyncStatusSnapshot({
required this.accountId,
required this.syncing,
required this.syncingFileIds,
required this.syncedFileIds,
required this.conflicts,
});
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
return SyncStatusSnapshot(
accountId: map['accountId'] as String?,
syncing: map['syncing'] as bool? ?? false,
syncingFileIds: ((map['syncingFileIds'] as List?) ?? const [])
.cast<String>()
.toSet(),
syncedFileIds: ((map['syncedFileIds'] as List?) ?? const [])
.cast<String>()
.toSet(),
conflicts: ((map['conflicts'] as List?) ?? const [])
.cast<Map<dynamic, dynamic>>()
.map(SyncConflictInfo.fromMap)
.toList(),
);
}
}
/// Talks to `SyncWorker`/`SyncEngine.kt`'s Android WorkManager-based device
/// sync (see `server.md`'s "Device sync" section for the full design) -
/// 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. 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 [SyncStatusController.syncedPaths] is now empty) or
/// re-enqueues the periodic sync job with fresh account/credentials/
/// path-list/network-constraint data - call this any time one of those
/// changes, since a periodic `WorkRequest`'s input is fixed at enqueue
/// time and can only be updated by re-enqueueing.
static Future<void> reschedule(
SessionController session,
SyncStatusController sync,
) async {
final accountId = session.activeAccountId;
final authHeader = session.service?.authHeaders['Authorization'];
if (accountId == null || authHeader == null) {
return cancel();
}
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
await _channel.invokeMethod('reschedule', {
'accountId': accountId,
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
'wifiOnly': !sync.syncOnCellular,
});
}
static Future<void> cancel() async {
await _channel.invokeMethod('cancel');
}
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
/// independent of the periodic schedule.
static Future<void> syncNow(
SessionController session,
SyncStatusController sync,
) async {
final accountId = session.activeAccountId;
final authHeader = session.service?.authHeaders['Authorization'];
if (accountId == null || authHeader == null) {
throw Exception('Not logged in.');
}
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
await _channel.invokeMethod('syncNow', {
'accountId': accountId,
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
});
}
/// One-shot status snapshot - used to seed [SyncStatusController]'s state
/// right after login/account-switch, before the first [statusStream]
/// event arrives.
static Future<SyncStatusSnapshot> getStatus() async {
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
'getSyncStatus',
);
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>),
);
}
/// 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(
SessionController session,
SyncConflictInfo conflict,
String resolution,
) async {
final authHeader = session.service?.authHeaders['Authorization'];
if (authHeader == null) throw Exception('Not logged in.');
await _channel.invokeMethod('resolveConflict', {
'accountId': conflict.accountId,
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'fileId': conflict.fileId,
'remotePath': conflict.remotePath,
'relPath': conflict.relPath,
'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;
}
}
+12 -14
View File
@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; 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'; import 'share_intent_service.dart';
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android /// 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 /// service owns the rest and reports progress/completion/cancellation
/// through its own notification, not back to the app. /// through its own notification, not back to the app.
static Future<void> startUpload( static Future<void> startUpload(
ServerProvider provider, SessionController session,
List<SharedFileRef> files, FilesController files,
List<SharedFileRef> sharedFiles,
) async { ) async {
final service = provider.service; final args = baseChannelArgs(session);
final authHeader = service?.authHeaders['Authorization'];
if (service == null || authHeader == null) {
throw Exception('Not logged in.');
}
final filesJson = jsonEncode( 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', { await _channel.invokeMethod('startUpload', {
...args,
'files': filesJson, 'files': filesJson,
'serverUrl': provider.serverUrl, 'remoteFolder': files.currentFolderPath,
'username': provider.username,
'authHeader': authHeader,
'remoteFolder': provider.currentFolderPath,
}); });
} }
} }
+283 -177
View File
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/saved_account.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 '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart'; import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart'; import '../widgets/seek_bar_painter.dart';
@@ -16,8 +20,10 @@ class AccountView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final session = context.watch<SessionController>();
final quota = provider.quota; final settings = context.watch<SettingsController>();
final files = context.watch<FilesController>();
final quota = files.quota;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(title: const Text('Settings')),
@@ -45,7 +51,7 @@ class AccountView extends StatelessWidget {
radius: 28, radius: 28,
backgroundColor: colorScheme.primary, backgroundColor: colorScheme.primary,
child: Text( child: Text(
(quota?.userName ?? provider.username) (quota?.userName ?? session.username)
.substring(0, 1) .substring(0, 1)
.toUpperCase(), .toUpperCase(),
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
@@ -60,14 +66,14 @@ class AccountView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
quota?.userName ?? provider.username, quota?.userName ?? session.username,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
quota?.email ?? provider.username, quota?.email ?? session.username,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -80,30 +86,6 @@ class AccountView extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 16), const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.public_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
Uri.tryParse(provider.serverUrl)?.host ??
provider.serverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -137,19 +119,33 @@ class AccountView extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Align( Row(
alignment: Alignment.centerRight, mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Text( children: [
quota != null Expanded(
? (quota.totalBytes > 0 child: Text(
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' Uri.tryParse(session.serverUrl)?.host ??
: 'Unlimited storage') session.serverUrl,
: '', maxLines: 1,
style: theme.textTheme.bodySmall?.copyWith( overflow: TextOverflow.ellipsis,
color: colorScheme.onSurfaceVariant, style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11, color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
), ),
), Text(
quota != null
? (quota.totalBytes > 0
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
: 'Unlimited storage')
: '',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
), ),
if (quota != null && quota.groups.isNotEmpty) ...[ if (quota != null && quota.groups.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -188,62 +184,35 @@ class AccountView extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Expanded( IconButton(
child: OutlinedButton( icon: const Icon(Icons.refresh_rounded),
onPressed: () async { tooltip: 'Refresh cached data',
await provider.refreshData(); onPressed: () async {
if (context.mounted) { await files.refreshData();
ScaffoldMessenger.of(context).showSnackBar( if (context.mounted) {
const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('Refreshed WebDAV data'), const SnackBar(
behavior: SnackBarBehavior.floating, content: Text('Refreshed WebDAV data'),
), behavior: SnackBarBehavior.floating,
); ),
} );
}, }
style: OutlinedButton.styleFrom( },
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Refresh Cache',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
), ),
const SizedBox(width: 8), IconButton(
Expanded( icon: const Icon(Icons.logout_rounded),
child: FilledButton( tooltip: 'Logout',
onPressed: () => _handleLogout(context, provider), color: Colors.orange.shade700,
style: FilledButton.styleFrom( onPressed: () => _handleLogout(context, session),
backgroundColor: colorScheme.errorContainer,
foregroundColor: colorScheme.onErrorContainer,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Logout',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
), ),
const SizedBox(width: 8), IconButton(
Expanded( icon: const Icon(Icons.delete_outline_rounded),
child: FilledButton( tooltip: 'Remove account',
onPressed: () => color: colorScheme.error,
_confirmRemoveActive(context, provider), onPressed: () =>
style: FilledButton.styleFrom( _confirmRemoveActive(context, session),
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Remove',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
), ),
], ],
), ),
@@ -274,6 +243,17 @@ class AccountView extends StatelessWidget {
const _SecurityCard(), const _SecurityCard(),
const SizedBox(height: 24), const SizedBox(height: 24),
// Device Sync Section
Text(
'Device Sync',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
const _DeviceSyncCard(),
const SizedBox(height: 24),
// Material You Design Settings // Material You Design Settings
Text( Text(
'Material You Aesthetics', 'Material You Aesthetics',
@@ -296,7 +276,7 @@ class AccountView extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
provider.useDynamicColor settings.useDynamicColor
? 'Matching your wallpaper' ? 'Matching your wallpaper'
: 'Custom color', : 'Custom color',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
@@ -308,8 +288,8 @@ class AccountView extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_AccentSwatch( _AccentSwatch(
isSelected: provider.useDynamicColor, isSelected: settings.useDynamicColor,
onTap: () => provider.setUseDynamicColor(true), onTap: () => settings.setUseDynamicColor(true),
borderColor: colorScheme.onSurface, borderColor: colorScheme.onSurface,
background: colorScheme.surfaceContainerHighest, background: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
@@ -320,11 +300,11 @@ class AccountView extends StatelessWidget {
), ),
...AppTheme.seedColors.map((color) { ...AppTheme.seedColors.map((color) {
final isSelected = final isSelected =
!provider.useDynamicColor && !settings.useDynamicColor &&
provider.seedColor == color; settings.seedColor == color;
return _AccentSwatch( return _AccentSwatch(
isSelected: isSelected, isSelected: isSelected,
onTap: () => provider.setSeedColor(color), onTap: () => settings.setSeedColor(color),
borderColor: colorScheme.onSurface, borderColor: colorScheme.onSurface,
background: color, background: color,
child: isSelected child: isSelected
@@ -368,9 +348,9 @@ class AccountView extends StatelessWidget {
label: Text('Dark'), label: Text('Dark'),
), ),
], ],
selected: {provider.themeMode}, selected: {settings.themeMode},
onSelectionChanged: (set) => onSelectionChanged: (set) =>
provider.setThemeMode(set.first), settings.setThemeMode(set.first),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -380,8 +360,8 @@ class AccountView extends StatelessWidget {
subtitle: const Text( subtitle: const Text(
'Use pure black backgrounds in dark mode', 'Use pure black backgrounds in dark mode',
), ),
value: provider.amoledDark, value: settings.amoledDark,
onChanged: provider.setAmoledDark, onChanged: settings.setAmoledDark,
), ),
], ],
), ),
@@ -405,8 +385,8 @@ class AccountView extends StatelessWidget {
subtitle: const Text( subtitle: const Text(
'Tapping the current bottom bar tab scrolls its list back to the top', 'Tapping the current bottom bar tab scrolls its list back to the top',
), ),
value: provider.tapTabToScrollTop, value: settings.tapTabToScrollTop,
onChanged: provider.setTapTabToScrollTop, onChanged: settings.setTapTabToScrollTop,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -487,24 +467,24 @@ class AccountView extends StatelessWidget {
); );
} }
/// Logout keeps the account saved (see [ServerProvider.logout]) so it's /// Logout keeps the account saved (see [SessionController.logout]) so
/// harmless/reversible from a one-tap resume on the login screen - /// it's harmless/reversible from a one-tap resume on the login screen -
/// doesn't need a confirmation dialog the way [_confirmRemoveActive] does. /// doesn't need a confirmation dialog the way [_confirmRemoveActive] does.
Future<void> _handleLogout( Future<void> _handleLogout(
BuildContext context, BuildContext context,
ServerProvider provider, SessionController session,
) async { ) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
await provider.logout(); await session.logout();
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst);
} }
Future<void> _confirmRemoveActive( Future<void> _confirmRemoveActive(
BuildContext context, BuildContext context,
ServerProvider provider, SessionController session,
) async { ) async {
final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl; final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl;
final hasOtherAccounts = provider.accounts.length > 1; final hasOtherAccounts = session.accounts.length > 1;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
@@ -512,8 +492,8 @@ class AccountView extends StatelessWidget {
title: const Text('Remove Account'), title: const Text('Remove Account'),
content: Text( content: Text(
hasOtherAccounts hasOtherAccounts
? 'Remove ${provider.username} ($host)? Another saved account will become active.' ? 'Remove ${session.username} ($host)? Another saved account will become active.'
: 'Remove ${provider.username} ($host)? You can add it again later.', : 'Remove ${session.username} ($host)? You can add it again later.',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -532,15 +512,15 @@ class AccountView extends StatelessWidget {
}, },
); );
if (confirmed != true) return; if (confirmed != true) return;
final id = provider.activeAccountId; final id = session.activeAccountId;
if (id == null) return; if (id == null) return;
if (!context.mounted) return; if (!context.mounted) return;
final navigator = Navigator.of(context); 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 // 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, // session actually ended - if it fell back to another saved account,
// Settings just keeps showing (now for that account) instead. // Settings just keeps showing (now for that account) instead.
if (!provider.isLoggedIn) { if (!session.isLoggedIn) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst);
} }
} }
@@ -554,10 +534,10 @@ class _AccountsCard extends StatelessWidget {
Future<void> _confirmRemove( Future<void> _confirmRemove(
BuildContext context, BuildContext context,
ServerProvider provider, SessionController session,
SavedAccount account, SavedAccount account,
) async { ) async {
final isActive = account.id == provider.activeAccountId; final isActive = account.id == session.activeAccountId;
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -565,7 +545,7 @@ class _AccountsCard extends StatelessWidget {
return AlertDialog( return AlertDialog(
title: const Text('Remove Account'), title: const Text('Remove Account'),
content: Text( 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)? Another saved account will become active.'
: 'Remove ${account.username} ($host)? You can add it again later.', : 'Remove ${account.username} ($host)? You can add it again later.',
), ),
@@ -588,13 +568,13 @@ class _AccountsCard extends StatelessWidget {
if (confirmed == true) { if (confirmed == true) {
if (!context.mounted) return; if (!context.mounted) return;
final navigator = Navigator.of(context); 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 // Only when this was the last saved account does isLoggedIn drop to
// false and main.dart swap the root route to LoginView underneath - // false and main.dart swap the root route to LoginView underneath -
// pop back to it then, rather than leaving Settings stranded on top. // pop back to it then, rather than leaving Settings stranded on top.
// Removing a non-active account, or falling back to another one, // Removing a non-active account, or falling back to another one,
// both keep the user logged in, so Settings should just stay put. // both keep the user logged in, so Settings should just stay put.
if (!provider.isLoggedIn) { if (!session.isLoggedIn) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst);
} }
} }
@@ -602,8 +582,8 @@ class _AccountsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final session = context.watch<SessionController>();
final accounts = provider.accounts; final accounts = session.accounts;
return Card( return Card(
child: Column( child: Column(
@@ -611,11 +591,11 @@ class _AccountsCard extends StatelessWidget {
for (final account in accounts) ...[ for (final account in accounts) ...[
_AccountRow( _AccountRow(
account: account, account: account,
isActive: account.id == provider.activeAccountId, isActive: account.id == session.activeAccountId,
onTap: account.id == provider.activeAccountId onTap: account.id == session.activeAccountId
? null ? null
: () => provider.switchAccount(account.id), : () => session.switchAccount(account.id),
onRemove: () => _confirmRemove(context, provider, account), onRemove: () => _confirmRemove(context, session, account),
), ),
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 16, endIndent: 16),
], ],
@@ -696,12 +676,12 @@ class _SecurityCard extends StatelessWidget {
Future<void> _handleLoginLockChanged( Future<void> _handleLoginLockChanged(
BuildContext context, BuildContext context,
ServerProvider provider, SessionController session,
bool value, bool value,
) async { ) async {
final success = value final success = value
? await provider.setupLoginLock() ? await session.setupLoginLock()
: await provider.disableLoginLock(); : await session.disableLoginLock();
if (!success && context.mounted) { if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -718,7 +698,7 @@ class _SecurityCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final session = context.watch<SessionController>();
return Card( return Card(
child: Column( child: Column(
@@ -729,18 +709,18 @@ class _SecurityCard extends StatelessWidget {
subtitle: const Text( subtitle: const Text(
"Require this device's PIN or biometric to open Noo", "Require this device's PIN or biometric to open Noo",
), ),
value: provider.loginLockEnabled, value: session.loginLockEnabled,
onChanged: (value) => onChanged: (value) =>
_handleLoginLockChanged(context, provider, value), _handleLoginLockChanged(context, session, value),
), ),
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.swap_horiz_rounded), secondary: const Icon(Icons.swap_horiz_rounded),
title: const Text('Lock account switching'), title: const Text('Lock account switching'),
subtitle: const Text('Unlock to switch between saved accounts'), subtitle: const Text('Unlock to switch between saved accounts'),
value: provider.lockAccountSwitching, value: session.lockAccountSwitching,
onChanged: provider.loginLockEnabled onChanged: session.loginLockEnabled
? provider.setLockAccountSwitching ? session.setLockAccountSwitching
: null, : null,
), ),
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 16, endIndent: 16),
@@ -748,9 +728,9 @@ class _SecurityCard extends StatelessWidget {
secondary: const Icon(Icons.visibility_off_rounded), secondary: const Icon(Icons.visibility_off_rounded),
title: const Text('Lock hidden files'), title: const Text('Lock hidden files'),
subtitle: const Text('Unlock to reveal hidden files and folders'), subtitle: const Text('Unlock to reveal hidden files and folders'),
value: provider.lockHiddenFiles, value: session.lockHiddenFiles,
onChanged: provider.loginLockEnabled onChanged: session.loginLockEnabled
? provider.setLockHiddenFiles ? session.setLockHiddenFiles
: null, : null,
), ),
], ],
@@ -759,6 +739,132 @@ class _SecurityCard extends StatelessWidget {
} }
} }
class _DeviceSyncCard extends StatefulWidget {
const _DeviceSyncCard();
@override
State<_DeviceSyncCard> createState() => _DeviceSyncCardState();
}
class _DeviceSyncCardState extends State<_DeviceSyncCard> {
bool _syncingNow = false;
Future<void> _syncNow(
SessionController session,
SyncStatusController sync,
) async {
setState(() => _syncingNow = true);
try {
await SyncService.syncNow(session, sync);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not start sync: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _syncingNow = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final sync = context.watch<SyncStatusController>();
final folders = sync.syncedPaths;
final everything = sync.syncEverything;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
secondary: const Icon(Icons.cloud_sync_rounded),
title: const Text('Sync everything'),
subtitle: const Text(
'Mirror the whole account instead of picking folders',
),
value: everything,
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,
),
),
)
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: () {
sync.removeSyncedPath(folder);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Removed from device sync'),
behavior: SnackBarBehavior.floating,
),
);
},
),
),
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: sync.syncOnCellular,
onChanged: sync.setSyncOnCellular,
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: _syncingNow
? const Padding(
padding: EdgeInsets.all(2),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.sync_rounded),
title: const Text('Sync now'),
enabled: (everything || folders.isNotEmpty) && !_syncingNow,
onTap: () => _syncNow(session, sync),
),
],
),
);
}
}
class _BottomBarAppearanceCard extends StatefulWidget { class _BottomBarAppearanceCard extends StatefulWidget {
const _BottomBarAppearanceCard(); const _BottomBarAppearanceCard();
@@ -768,9 +874,9 @@ class _BottomBarAppearanceCard extends StatefulWidget {
} }
class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { 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 // 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 // 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 // blur in the preview — which can't keep up with fast drag ticks and makes
// the thumb appear stuck until the next unrelated rebuild. // the thumb appear stuck until the next unrelated rebuild.
@@ -781,9 +887,9 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final settings = context.watch<SettingsController>();
final opacity = _opacity ?? provider.bottomBarOpacity; final opacity = _opacity ?? settings.bottomBarOpacity;
final blur = _blur ?? provider.bottomBarBlur; final blur = _blur ?? settings.bottomBarBlur;
return Card( return Card(
child: Padding( child: Padding(
@@ -885,7 +991,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
min: 0.1, min: 0.1,
max: 1.0, max: 1.0,
onChanged: (value) => setState(() => _opacity = value), onChanged: (value) => setState(() => _opacity = value),
onChangeEnd: provider.setBottomBarOpacity, onChangeEnd: settings.setBottomBarOpacity,
), ),
Row( Row(
@@ -905,7 +1011,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
min: 0, min: 0,
max: 40, max: 40,
onChanged: (value) => setState(() => _blur = value), onChanged: (value) => setState(() => _blur = value),
onChangeEnd: provider.setBottomBarBlur, onChangeEnd: settings.setBottomBarBlur,
), ),
], ],
), ),
@@ -986,7 +1092,7 @@ class _SwipeActionsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final provider = context.watch<ServerProvider>(); final settings = context.watch<SettingsController>();
return Card( return Card(
child: Padding( child: Padding(
@@ -997,16 +1103,16 @@ class _SwipeActionsCard extends StatelessWidget {
context, context,
theme, theme,
label: 'Swipe right', label: 'Swipe right',
value: provider.swipeRightAction, value: settings.swipeRightAction,
onChanged: provider.setSwipeRightAction, onChanged: settings.setSwipeRightAction,
), ),
const Divider(height: 1), const Divider(height: 1),
_buildRow( _buildRow(
context, context,
theme, theme,
label: 'Swipe left', label: 'Swipe left',
value: provider.swipeLeftAction, value: settings.swipeLeftAction,
onChanged: provider.setSwipeLeftAction, onChanged: settings.setSwipeLeftAction,
), ),
], ],
), ),
@@ -1068,21 +1174,21 @@ class _MediaPlayerCard extends StatelessWidget {
Future<void> _openPicker( Future<void> _openPicker(
BuildContext context, BuildContext context,
ServerProvider provider, SettingsController settings,
) async { ) async {
final selected = await showDialog<MediaProgressBarStyle>( final selected = await showDialog<MediaProgressBarStyle>(
context: context, context: context,
builder: (_) => builder: (_) =>
_SeekBarStyleDialog(current: provider.mediaProgressBarStyle), _SeekBarStyleDialog(current: settings.mediaProgressBarStyle),
); );
if (selected != null) provider.setMediaProgressBarStyle(selected); if (selected != null) settings.setMediaProgressBarStyle(selected);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final settings = context.watch<SettingsController>();
return Card( return Card(
child: ListTile( child: ListTile(
@@ -1093,17 +1199,17 @@ class _MediaPlayerCard extends StatelessWidget {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
subtitle: Text(_seekBarStyleLabel(provider.mediaProgressBarStyle)), subtitle: Text(_seekBarStyleLabel(settings.mediaProgressBarStyle)),
trailing: SizedBox( trailing: SizedBox(
width: 64, width: 64,
height: 24, height: 24,
child: SeekBarPreview( child: SeekBarPreview(
style: provider.mediaProgressBarStyle, style: settings.mediaProgressBarStyle,
playedColor: colorScheme.primary, playedColor: colorScheme.primary,
trackColor: colorScheme.outlineVariant, trackColor: colorScheme.outlineVariant,
), ),
), ),
onTap: () => _openPicker(context, provider), onTap: () => _openPicker(context, settings),
), ),
); );
} }
@@ -1265,7 +1371,7 @@ class _CacheSettingsCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final files = context.watch<FilesController>();
return Card( return Card(
child: Padding( child: Padding(
@@ -1273,9 +1379,9 @@ class _CacheSettingsCard extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
RadioGroup<CachePolicy>( RadioGroup<CachePolicy>(
groupValue: provider.cachePolicy, groupValue: files.cachePolicy,
onChanged: (value) { onChanged: (value) {
if (value != null) provider.setCachePolicy(value); if (value != null) files.setCachePolicy(value);
}, },
child: Column( child: Column(
children: [ children: [
@@ -1288,7 +1394,7 @@ class _CacheSettingsCard extends StatelessWidget {
], ],
), ),
), ),
if (provider.cachePolicy == CachePolicy.interval) if (files.cachePolicy == CachePolicy.interval)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column( child: Column(
@@ -1303,7 +1409,7 @@ class _CacheSettingsCard extends StatelessWidget {
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
Text( Text(
'${provider.cacheIntervalMinutes} min', '${files.cacheIntervalMinutes} min',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -1311,13 +1417,13 @@ class _CacheSettingsCard extends StatelessWidget {
], ],
), ),
Slider( Slider(
value: provider.cacheIntervalMinutes.toDouble(), value: files.cacheIntervalMinutes.toDouble(),
min: 1, min: 1,
max: 60, max: 60,
divisions: 59, divisions: 59,
label: '${provider.cacheIntervalMinutes} min', label: '${files.cacheIntervalMinutes} min',
onChanged: (value) => onChanged: (value) =>
provider.setCacheIntervalMinutes(value.round()), files.setCacheIntervalMinutes(value.round()),
), ),
], ],
), ),
@@ -1334,7 +1440,7 @@ class _TabSettingsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final settings = context.watch<SettingsController>();
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -1342,20 +1448,20 @@ class _TabSettingsCard extends StatelessWidget {
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
onReorderItem: (oldIndex, newIndex) { onReorderItem: (oldIndex, newIndex) {
final order = List<AppTab>.from(provider.tabOrder); final order = List<AppTab>.from(settings.tabOrder);
final tab = order.removeAt(oldIndex); final tab = order.removeAt(oldIndex);
order.insert(newIndex, tab); order.insert(newIndex, tab);
provider.setTabOrder(order); settings.setTabOrder(order);
}, },
children: [ children: [
for (final tab in provider.tabOrder) for (final tab in settings.tabOrder)
_TabConfigRow( _TabConfigRow(
key: ValueKey(tab), key: ValueKey(tab),
tab: tab, tab: tab,
isVisible: !provider.hiddenTabs.contains(tab), isVisible: !settings.hiddenTabs.contains(tab),
isDefault: provider.defaultTab == tab, isDefault: settings.defaultTab == tab,
onVisibilityChanged: (value) { onVisibilityChanged: (value) {
final error = provider.setTabHidden(tab, !value); final error = settings.setTabHidden(tab, !value);
if (error != null && context.mounted) { if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -1365,7 +1471,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:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/server_provider.dart'; import '../providers/files_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/synced_header_scaffold.dart';
@@ -15,13 +15,13 @@ class ActivityView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final files = context.watch<FilesController>();
final activities = provider.activities; final activities = files.activities;
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isLoading && activities.isEmpty) if (files.isLoading && activities.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@@ -134,7 +134,6 @@ class ActivityView extends StatelessWidget {
return SyncedHeaderScaffold( return SyncedHeaderScaffold(
scrollController: scrollController, scrollController: scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()], actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers, contentSlivers: contentSlivers,
); );
+787
View File
@@ -0,0 +1,787 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.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';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
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 `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-
/// wide list also means the breadcrumb-vs-content mismatch a filter
/// 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 `FilesController.applyFilesDisplayPrefs`) rather than a separate
/// parallel settings dimension.
class FavoritesView extends StatefulWidget {
final ScrollController scrollController;
const FavoritesView({super.key, required this.scrollController});
@override
State<FavoritesView> createState() => _FavoritesViewState();
}
class _FavoritesViewState extends State<FavoritesView>
with SingleTickerProviderStateMixin {
bool _requested = false;
final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
}
void _clearSelection() {
setState(_selectedIds.clear);
}
/// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their
/// doc comment: nudges the selection actions row right and back, once,
/// the first time a selection starts. No-ops if there's nothing to
/// scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_selectionActionsScrollController.dispose();
super.dispose();
}
/// A favorited folder switches to the Files tab, navigated there; a
/// favorited file just opens directly from here, like Files/Photos do -
/// no need to reposition Files first since dismissing the viewer lands
/// back on this tab either way.
void _openFavorite(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
if (item.isFolder) {
context.read<FilesController>().navigateToAbsoluteFolder(item.path);
context.read<SettingsController>().requestTab(AppTab.files);
} else {
Navigator.push(
context,
FileViewerScreen.route(item: item, siblings: siblings),
);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items - same set Files/Photos offer.
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
return [
SelectionAction(
icon: Icons.favorite_border_rounded,
label: 'Remove from favorites',
onTap: () => _unfavoriteSelected(selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
}
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
final ops = context.read<ItemOperations>();
for (final item in items) {
await ops.toggleItemFavorite(item);
}
_clearSelection();
}
Future<void> _shareSelected(
BuildContext context,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
content: Text('Creating share link(s)…'),
behavior: SnackBarBehavior.floating,
),
);
final lines = <String>[];
for (final item in items) {
final link = await ops.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
}
if (!mounted) return;
messenger.hideCurrentSnackBar();
_clearSelection();
if (lines.isEmpty) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create share link(s)'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
try {
await SharePlus.instance.share(ShareParams(text: lines.join('\n')));
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Could not open the share sheet'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// Hands the whole batch off to `DownloadService.kt` - see
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
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(session, files);
messenger.showSnackBar(
SnackBar(
content: Text(
files.length == 1
? 'Downloading ${files.first.name} - see the notification for progress'
: 'Downloading ${files.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start download: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _confirmDeleteSelected(
BuildContext context,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Items'),
content: Text(
'Delete ${items.length} item(s) from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
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 ops.deleteItem(item.path);
if (success) succeeded++;
}
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _moveOrCopySelected(
List<NextcloudItem> items, {
required bool copy,
}) async {
final result = await MoveCopyDestinationPicker.show(
context,
items,
copy: copy,
);
if (!mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${copy ? 'Copied' : 'Moved'} ${result.succeeded} of ${items.length} item(s)',
),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
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),
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.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',
),
],
),
],
),
),
);
}
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
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(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.5)
: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
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 ||
item.type == NextcloudItemType.video) &&
item.previewUrl != null;
final iconColor = getIconColor(context, item.type);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Material(
color: theme.colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? Image.network(
item.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => Container(
color: theme.colorScheme.secondaryContainer,
child: Icon(
getItemIcon(item.type),
color: theme.colorScheme.onSecondaryContainer,
size: 32,
),
),
)
: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final favoritesController = context.watch<FavoritesController>();
final filesController = context.watch<FilesController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => favoritesController.fetchAll(),
);
}
final favorites = favoritesController.items;
final siblings = favorites.where((i) => !i.isFolder).toList();
final selectedItems = favorites
.where((i) => _selectedIds.contains(i.id))
.toList();
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
height: 60,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: _buildControlsRow(filesController),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (favoritesController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load favorites',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
favoritesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: favoritesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (favorites.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_border_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No favorites yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else if (filesController.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, favorites[index], siblings);
}, childCount: favorites.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, favorites[index], siblings);
}, childCount: favorites.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
canPop: !_isSelecting,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: favoritesController.fetchAll,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, selectedItems)
: null,
contentSlivers: contentSlivers,
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical
/// selection bar.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
List<NextcloudItem> selectedItems,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(selectedItems))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
);
}
}
+58 -101
View File
@@ -1,9 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marquee/marquee.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -11,9 +9,13 @@ import 'package:pdfx/pdfx.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../models/nextcloud_item.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/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart'; import '../widgets/frosted_glass_container.dart';
import '../widgets/marquee_title.dart';
import '../widgets/seek_bar_painter.dart'; import '../widgets/seek_bar_painter.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
@@ -148,13 +150,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
} }
Future<String> _downloadToTemp( Future<String> _downloadToTemp(
ServerProvider provider, SessionController session,
NextcloudItem item, NextcloudItem item,
) async { ) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final savePath = p.join(tempDir.path, item.name); final savePath = p.join(tempDir.path, item.name);
setState(() => _downloadProgress = 0); setState(() => _downloadProgress = 0);
await provider.service!.downloadToFile( await session.service!.downloadToFile(
item.path, item.path,
savePath, savePath,
onProgress: (received, total) { onProgress: (received, total) {
@@ -167,11 +169,11 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return savePath; return savePath;
} }
Future<void> _openExternally(ServerProvider provider) async { Future<void> _openExternally(SessionController session) async {
final item = _currentItem; final item = _currentItem;
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final path = await _downloadToTemp(provider, item); final path = await _downloadToTemp(session, item);
final result = await OpenFile.open(path); final result = await OpenFile.open(path);
if (mounted && result.type != ResultType.done) { if (mounted && result.type != ResultType.done) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -189,21 +191,22 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
} }
} }
Future<void> _downloadToDevice(ServerProvider provider) async { /// Hands off to `DownloadService.kt` (see its doc comment) rather than
/// downloading in Dart then prompting `file_saver` - same reasoning as
/// `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(SessionController session) async {
final item = _currentItem; final item = _currentItem;
setState(() => _isBusy = true);
try { try {
final tempPath = await _downloadToTemp(provider, item); await DownloadService.startDownload(session, [item]);
final ext = p.extension(item.name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(item.name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved ${item.name} to Downloads')), SnackBar(
content: Text(
'Downloading ${item.name} - see the notification for progress',
),
),
); );
} }
} catch (e) { } catch (e) {
@@ -212,21 +215,19 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
context, context,
).showSnackBar(SnackBar(content: Text('Download failed: $e'))); ).showSnackBar(SnackBar(content: Text('Download failed: $e')));
} }
} finally {
if (mounted) setState(() => _isBusy = false);
} }
} }
Future<void> _toggleFavorite(ServerProvider provider) async { Future<void> _toggleFavorite(ItemOperations ops) async {
final item = _currentItem; final item = _currentItem;
await provider.toggleItemFavorite(item); await ops.toggleItemFavorite(item);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_mediaItems[_currentIndex] = item.copyWith(isFavorite: !item.isFavorite); _mediaItems[_currentIndex] = item.copyWith(isFavorite: !item.isFavorite);
}); });
} }
Future<void> _deleteCurrentItem(ServerProvider provider) async { Future<void> _deleteCurrentItem(ItemOperations ops) async {
final item = _currentItem; final item = _currentItem;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -255,7 +256,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
if (confirmed != true || !mounted) return; if (confirmed != true || !mounted) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
final success = await provider.deleteItem(item.path); final success = await ops.deleteItem(item.path);
if (!mounted) return; if (!mounted) return;
setState(() => _isBusy = false); setState(() => _isBusy = false);
@@ -284,7 +285,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
@override @override
Widget build(BuildContext context) { 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; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
@@ -297,7 +300,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
Positioned.fill( Positioned.fill(
child: Container( child: Container(
color: colorScheme.surface, color: colorScheme.surface,
child: _buildBody(context, provider), child: _buildBody(context, session),
), ),
), ),
if (_downloadProgress != null) if (_downloadProgress != null)
@@ -323,8 +326,8 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: FrostedGlassContainer( child: FrostedGlassContainer(
opacity: provider.bottomBarOpacity, opacity: settings.bottomBarOpacity,
blurSigma: provider.bottomBarBlur, blurSigma: settings.bottomBarBlur,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
@@ -339,7 +342,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: _MarqueeTitle( child: MarqueeTitle(
text: _currentItem.name, text: _currentItem.name,
style: Theme.of(context).textTheme.titleSmall style: Theme.of(context).textTheme.titleSmall
?.copyWith( ?.copyWith(
@@ -368,13 +371,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
child: _MediaActionBar( child: _MediaActionBar(
isFavorite: _currentItem.isFavorite, isFavorite: _currentItem.isFavorite,
isBusy: _isBusy, isBusy: _isBusy,
opacity: provider.bottomBarOpacity, opacity: settings.bottomBarOpacity,
blurSigma: provider.bottomBarBlur, blurSigma: settings.bottomBarBlur,
onShare: () => ShareSheet.show(context, _currentItem), onShare: () => ShareSheet.show(context, _currentItem),
onFavorite: () => _toggleFavorite(provider), onFavorite: () => _toggleFavorite(ops),
onDelete: () => _deleteCurrentItem(provider), onDelete: () => _deleteCurrentItem(ops),
onOpenExternally: () => _openExternally(provider), onOpenExternally: () => _openExternally(session),
onDownload: () => _downloadToDevice(provider), onDownload: () => _downloadToDevice(session),
onDetails: () => DetailsSheet.show(context, _currentItem), onDetails: () => DetailsSheet.show(context, _currentItem),
), ),
), ),
@@ -386,10 +389,11 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
); );
} }
Widget _buildBody(BuildContext context, ServerProvider provider) { Widget _buildBody(BuildContext context, SessionController session) {
if (!_isSwipeable) { if (!_isSwipeable) {
return _buildStaticPreview(context, provider); return _buildStaticPreview(context, session);
} }
final settings = context.read<SettingsController>();
return PageView.builder( return PageView.builder(
controller: _pageController, controller: _pageController,
@@ -400,24 +404,24 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
if (mediaItem.type == NextcloudItemType.image) { if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview( return _ImagePreview(
key: ValueKey(mediaItem.id), key: ValueKey(mediaItem.id),
url: provider.service!.fileUrl(mediaItem.path), url: session.service!.fileUrl(mediaItem.path),
headers: provider.service!.authHeaders, headers: session.service!.authHeaders,
); );
} }
return _VideoPreview( return _VideoPreview(
key: ValueKey(mediaItem.id), key: ValueKey(mediaItem.id),
url: provider.service!.fileUrl(mediaItem.path), url: session.service!.fileUrl(mediaItem.path),
headers: provider.service!.authHeaders, headers: session.service!.authHeaders,
isActive: index == _currentIndex, isActive: index == _currentIndex,
controlsBottomOffset: _controlsVisible ? 108 : 24, controlsBottomOffset: _controlsVisible ? 108 : 24,
opacity: provider.bottomBarOpacity, opacity: settings.bottomBarOpacity,
blurSigma: provider.bottomBarBlur, blurSigma: settings.bottomBarBlur,
); );
}, },
); );
} }
Widget _buildStaticPreview(BuildContext context, ServerProvider provider) { Widget _buildStaticPreview(BuildContext context, SessionController session) {
switch (widget.item.type) { switch (widget.item.type) {
case NextcloudItemType.image: case NextcloudItemType.image:
case NextcloudItemType.video: case NextcloudItemType.video:
@@ -425,15 +429,15 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return const SizedBox.shrink(); return const SizedBox.shrink();
default: default:
if (_isPdf) { if (_isPdf) {
return _PdfPreview(item: widget.item, provider: provider); return _PdfPreview(item: widget.item, session: session);
} }
if (_isText) { if (_isText) {
return _TextPreview(item: widget.item, provider: provider); return _TextPreview(item: widget.item, session: session);
} }
return _UnsupportedPreview( return _UnsupportedPreview(
item: widget.item, item: widget.item,
isBusy: _isBusy, isBusy: _isBusy,
onOpenExternally: () => _openExternally(provider), onOpenExternally: () => _openExternally(session),
onOpenDetails: () => DetailsSheet.show(context, widget.item), onOpenDetails: () => DetailsSheet.show(context, widget.item),
); );
} }
@@ -562,53 +566,6 @@ class _ActionIconButton extends StatelessWidget {
} }
} }
/// The media viewer's title: a plain, single-line ellipsized [Text] for
/// names that fit, or an auto-scrolling [Marquee] for names too long for
/// the available width - measured once via [TextPainter] rather than
/// always marqueeing, so a short filename just sits still like normal.
class _MarqueeTitle extends StatelessWidget {
final String text;
final TextStyle? style;
const _MarqueeTitle({required this.text, required this.style});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
if (painter.width <= constraints.maxWidth) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
);
}
return SizedBox(
height: painter.height,
child: Marquee(
text: text,
style: style,
blankSpace: 48,
velocity: 30,
startPadding: 0,
pauseAfterRound: const Duration(seconds: 1),
fadingEdgeStartFraction: 0.1,
fadingEdgeEndFraction: 0.15,
),
);
},
);
}
}
class _ImagePreview extends StatefulWidget { class _ImagePreview extends StatefulWidget {
final String url; final String url;
final Map<String, String> headers; final Map<String, String> headers;
@@ -800,7 +757,7 @@ class _VideoPreviewState extends State<_VideoPreview> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final progressBarStyle = context final progressBarStyle = context
.watch<ServerProvider>() .watch<SettingsController>()
.mediaProgressBarStyle; .mediaProgressBarStyle;
if (_error != null) { if (_error != null) {
return Center( return Center(
@@ -1014,9 +971,9 @@ class _WavySeekBarState extends State<_WavySeekBar>
class _PdfPreview extends StatefulWidget { class _PdfPreview extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
final ServerProvider provider; final SessionController session;
const _PdfPreview({required this.item, required this.provider}); const _PdfPreview({required this.item, required this.session});
@override @override
State<_PdfPreview> createState() => _PdfPreviewState(); State<_PdfPreview> createState() => _PdfPreviewState();
@@ -1034,7 +991,7 @@ class _PdfPreviewState extends State<_PdfPreview> {
Future<void> _load() async { Future<void> _load() async {
try { try {
final bytes = await widget.provider.service!.fetchBytes(widget.item.path); final bytes = await widget.session.service!.fetchBytes(widget.item.path);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_controller = PdfControllerPinch( _controller = PdfControllerPinch(
@@ -1081,9 +1038,9 @@ class _PdfPreviewState extends State<_PdfPreview> {
class _TextPreview extends StatefulWidget { class _TextPreview extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
final ServerProvider provider; final SessionController session;
const _TextPreview({required this.item, required this.provider}); const _TextPreview({required this.item, required this.session});
@override @override
State<_TextPreview> createState() => _TextPreviewState(); State<_TextPreview> createState() => _TextPreviewState();
@@ -1101,7 +1058,7 @@ class _TextPreviewState extends State<_TextPreview> {
Future<void> _load() async { Future<void> _load() async {
try { try {
final bytes = await widget.provider.service!.fetchBytes(widget.item.path); final bytes = await widget.session.service!.fetchBytes(widget.item.path);
if (mounted) { if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true)); setState(() => _content = utf8.decode(bytes, allowMalformed: true));
} }
+489 -287
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:flutter/material.dart';
import 'package:provider/provider.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 /// true - a fresh app launch with login lock configured, or returning to the
/// foreground after being backgrounded. Prompts automatically on first show /// foreground after being backgrounded. Prompts automatically on first show
/// and again whenever the app resumes while still locked (e.g. the user /// 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) { if (state != AppLifecycleState.resumed || !mounted || _authenticating) {
return; return;
} }
if (context.read<ServerProvider>().needsUnlock) _attemptUnlock(); if (context.read<SessionController>().needsUnlock) _attemptUnlock();
} }
Future<void> _attemptUnlock() async { Future<void> _attemptUnlock() async {
if (_authenticating || !mounted) return; if (_authenticating || !mounted) return;
setState(() => _authenticating = true); setState(() => _authenticating = true);
await context.read<ServerProvider>().attemptUnlock(); await context.read<SessionController>().attemptUnlock();
if (mounted) setState(() => _authenticating = false); if (mounted) setState(() => _authenticating = false);
} }
+109 -26
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/saved_account.dart'; import '../models/saved_account.dart';
import '../providers/server_provider.dart'; import '../providers/session_controller.dart';
import 'login_webview_view.dart'; import 'login_webview_view.dart';
class LoginView extends StatefulWidget { class LoginView extends StatefulWidget {
@@ -28,7 +28,7 @@ class _LoginViewState extends State<LoginView> {
void initState() { void initState() {
super.initState(); super.initState();
if (widget.isAddingAccount) { if (widget.isAddingAccount) {
_originalActiveAccountId = context.read<ServerProvider>().activeAccountId; _originalActiveAccountId = context.read<SessionController>().activeAccountId;
} }
} }
@@ -41,28 +41,82 @@ class _LoginViewState extends State<LoginView> {
void _handleContinue() { void _handleContinue() {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
context.read<ServerProvider>().startLoginFlow( context.read<SessionController>().startLoginFlow(
_urlController.text.trim(), _urlController.text.trim(),
addAccount: widget.isAddingAccount, addAccount: widget.isAddingAccount,
); );
} }
/// 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
/// a plain network hiccup) - so this surfaces that as a SnackBar with a
/// silently failing every time - a delete button on the row itself (see
/// [_SavedAccountRow]) is the way out.
Future<void> _continueAsAccount(SavedAccount account) async {
final session = context.read<SessionController>();
final success = await session.switchAccount(account.id);
if (success || !mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text("Couldn't sign in as ${account.username}"),
behavior: SnackBarBehavior.floating,
),
);
}
/// Mirrors AccountView's `_AccountsCard._confirmRemove` - same
/// destructive-action gate, just reached from the login screen instead of
/// Settings.
Future<void> _confirmRemoveAccount(SavedAccount account) async {
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Remove Account'),
content: Text(
'Remove ${account.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed != true || !mounted) return;
await context.read<SessionController>().removeAccount(account.id);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final session = context.watch<SessionController>();
final isAwaitingBrowser = final isAwaitingBrowser =
provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser; session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating; final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
// A new/refreshed account has just become active - pop back to // A new/refreshed account has just become active - pop back to
// Settings rather than leaving this form sitting on top of it. // Settings rather than leaving this form sitting on top of it.
if (widget.isAddingAccount && if (widget.isAddingAccount &&
!_popped && !_popped &&
provider.loginFlowStatus == LoginFlowStatus.idle && session.loginFlowStatus == LoginFlowStatus.idle &&
provider.activeAccountId != _originalActiveAccountId) { session.activeAccountId != _originalActiveAccountId) {
_popped = true; _popped = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
@@ -71,19 +125,31 @@ class _LoginViewState extends State<LoginView> {
// Every login (first account or an additional one) shows its login // Every login (first account or an additional one) shows its login
// page in LoginWebViewView, a real screen this app owns - see // 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 // moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again. // pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed && if (!_webViewPushed &&
isAwaitingBrowser && isAwaitingBrowser &&
provider.pendingLoginUrl != null) { session.pendingLoginUrl != null) {
_webViewPushed = true; _webViewPushed = true;
final url = provider.pendingLoginUrl!; final url = session.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return; if (!mounted) return;
await Navigator.of( // A plain MaterialPageRoute's animated slide/fade transition can
context, // leave this screen fully black after popping back off of it - a
).push(MaterialPageRoute(builder: (_) => LoginWebViewView(url: url))); // known Android WebView-as-PlatformView compositing issue with the
// Impeller renderer (the platform view's surface isn't always torn
// down in sync with an animated route transition). An instant,
// non-animated route sidesteps it entirely; the close (X) button
// already reads like a immediate action, not something that needs
// a slide, so no UX is lost.
await Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (_, _, _) => LoginWebViewView(url: url),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
),
);
_webViewPushed = false; _webViewPushed = false;
}); });
} }
@@ -107,10 +173,11 @@ class _LoginViewState extends State<LoginView> {
// from here with one tap - no need to repeat // from here with one tap - no need to repeat
// Login Flow v2. // Login Flow v2.
if (!widget.isAddingAccount && if (!widget.isAddingAccount &&
provider.accounts.isNotEmpty) ...[ session.accounts.isNotEmpty) ...[
_SavedAccountsSection( _SavedAccountsSection(
accounts: provider.accounts, accounts: session.accounts,
onSelect: (account) => provider.switchAccount(account.id), onSelect: (account) => _continueAsAccount(account),
onRemove: (account) => _confirmRemoveAccount(account),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Row( Row(
@@ -134,10 +201,10 @@ class _LoginViewState extends State<LoginView> {
formKey: _formKey, formKey: _formKey,
urlController: _urlController, urlController: _urlController,
isLoading: isLoading:
isInitiating || provider.isLoading || isAwaitingBrowser, isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage: errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error session.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage ? session.errorMessage
: null, : null,
onContinue: _handleContinue, onContinue: _handleContinue,
theme: theme, theme: theme,
@@ -159,9 +226,9 @@ class _LoginViewState extends State<LoginView> {
// gone - this view could never be popped before "add account" existed, // gone - this view could never be popped before "add account" existed,
// so that case wasn't reachable until now. // so that case wasn't reachable until now.
return PopScope( return PopScope(
canPop: provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser, canPop: session.loginFlowStatus != LoginFlowStatus.awaitingBrowser,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) provider.cancelLoginFlow(); if (!didPop) session.cancelLoginFlow();
}, },
child: body, child: body,
); );
@@ -325,12 +392,17 @@ class _ServerForm extends StatelessWidget {
} }
/// Saved accounts a logout left recoverable - tapping one resumes it via /// 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 { class _SavedAccountsSection extends StatelessWidget {
final List<SavedAccount> accounts; final List<SavedAccount> accounts;
final ValueChanged<SavedAccount> onSelect; final ValueChanged<SavedAccount> onSelect;
final ValueChanged<SavedAccount> onRemove;
const _SavedAccountsSection({required this.accounts, required this.onSelect}); const _SavedAccountsSection({
required this.accounts,
required this.onSelect,
required this.onRemove,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -358,6 +430,7 @@ class _SavedAccountsSection extends StatelessWidget {
_SavedAccountRow( _SavedAccountRow(
account: account, account: account,
onTap: () => onSelect(account), onTap: () => onSelect(account),
onRemove: () => onRemove(account),
), ),
if (account != accounts.last) if (account != accounts.last)
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 16, endIndent: 16),
@@ -373,8 +446,13 @@ class _SavedAccountsSection extends StatelessWidget {
class _SavedAccountRow extends StatelessWidget { class _SavedAccountRow extends StatelessWidget {
final SavedAccount account; final SavedAccount account;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onRemove;
const _SavedAccountRow({required this.account, required this.onTap}); const _SavedAccountRow({
required this.account,
required this.onTap,
required this.onRemove,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -397,7 +475,12 @@ class _SavedAccountRow extends StatelessWidget {
), ),
title: Text(account.username), title: Text(account.username),
subtitle: Text(host), subtitle: Text(host),
trailing: const Icon(Icons.chevron_right_rounded), trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Remove account',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
onTap: onTap, onTap: onTap,
); );
} }
+16 -7
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.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 - /// Every Login Flow v2 page - first login or an additional account -
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or /// 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, /// exactly like every other screen in the app - and unlike a Custom Tab,
/// this screen can close itself automatically once login succeeds. /// 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 { class LoginWebViewView extends StatefulWidget {
final Uri url; final Uri url;
@@ -42,24 +42,33 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
if (mounted) setState(() => _pageLoading = false); if (mounted) setState(() => _pageLoading = false);
}, },
), ),
) );
..loadRequest(widget.url); // Android's WebView CookieManager is a single store shared/persisted
// across every WebView instance in the app, not scoped to this
// controller - without clearing it first, "Add Account" (or logging
// back in as a different user on the same server) would silently land
// on whichever account's Nextcloud session cookie is already there
// instead of prompting for credentials, defeating the point of
// starting a fresh Login Flow v2 at all.
WebViewCookieManager().clearCookies().then((_) {
if (mounted) _controller.loadRequest(widget.url);
});
} }
void _cancel() { void _cancel() {
context.read<ServerProvider>().cancelLoginFlow(); context.read<SessionController>().cancelLoginFlow();
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final session = context.watch<SessionController>();
// The login flow finished (success or error) or was cancelled // The login flow finished (success or error) or was cancelled
// elsewhere - close this screen automatically rather than leaving it // elsewhere - close this screen automatically rather than leaving it
// sitting open over a flow that's no longer awaiting the browser. // sitting open over a flow that's no longer awaiting the browser.
if (!_popped && if (!_popped &&
provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser) { session.loginFlowStatus != LoginFlowStatus.awaitingBrowser) {
_popped = true; _popped = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
+533
View File
@@ -0,0 +1,533 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
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/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';
import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
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 `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 `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
/// launch has no prior browsing session to preserve); that doesn't hold
/// here.
class MoveCopyDestinationPicker extends StatefulWidget {
final List<NextcloudItem> items;
final bool copy;
const MoveCopyDestinationPicker({
super.key,
required this.items,
required this.copy,
});
/// Pushes the picker and returns the final [MoveCopyResult] (already
/// including any conflict resolution), or null if the user backed out
/// without confirming.
static Future<MoveCopyResult?> show(
BuildContext context,
List<NextcloudItem> items, {
required bool copy,
}) {
return Navigator.of(context).push<MoveCopyResult>(
MaterialPageRoute(
builder: (_) => MoveCopyDestinationPicker(items: items, copy: copy),
),
);
}
@override
State<MoveCopyDestinationPicker> createState() =>
_MoveCopyDestinationPickerState();
}
class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
final ScrollController _scrollController = ScrollController();
List<String> _pathStack = const ['/'];
List<NextcloudItem> _rawItems = [];
bool _isLoading = true;
bool _isSubmitting = false;
String get _currentPath => _pathStack.last;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch(_currentPath));
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _fetch(String path) async {
setState(() => _isLoading = true);
final raw = await context.read<FilesController>().fetchFolderListing(
path,
);
if (!mounted) return;
setState(() {
_rawItems = raw;
_isLoading = false;
});
}
void _navigateToFolder(String path) {
setState(() => _pathStack = [..._pathStack, path]);
_fetch(path);
}
void _navigateToPathIndex(int index) {
if (index < 0 || index >= _pathStack.length - 1) return;
setState(() => _pathStack = _pathStack.sublist(0, index + 1));
_fetch(_currentPath);
}
bool _navigateUp() {
if (_pathStack.length <= 1) return false;
setState(() => _pathStack = _pathStack.sublist(0, _pathStack.length - 1));
_fetch(_currentPath);
return true;
}
/// 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.
bool _destinationIsInvalid() {
final dest = _currentPath.endsWith('/') ? _currentPath : '$_currentPath/';
for (final item in widget.items) {
if (!item.isFolder) continue;
final folder = item.path.endsWith('/') ? item.path : '${item.path}/';
if (dest == folder || dest.startsWith(folder)) return true;
}
return false;
}
Future<void> _confirm(ItemOperations ops) async {
setState(() => _isSubmitting = true);
final result = widget.copy
? await ops.copyItems(widget.items, _currentPath)
: await ops.moveItems(widget.items, _currentPath);
if (!mounted) return;
if (result.blockedReason != null) {
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.blockedReason!),
behavior: SnackBarBehavior.floating,
),
);
return;
}
var succeeded = result.succeeded;
var failed = result.failed;
if (result.conflicts.isNotEmpty) {
final choices = await MoveCopyConflictSheet.show(
context,
result.conflicts,
);
if (!mounted) return;
if (choices != null) {
final resolved = await ops.resolveConflicts(
result.conflicts,
_currentPath,
copy: widget.copy,
choices: choices,
);
succeeded += resolved.succeeded;
failed += resolved.failed;
}
// A dismissed sheet (choices == null) leaves those conflicts
// untouched at the source - same outcome as explicitly skipping
// every one, just without a network round-trip to get there.
}
if (!mounted) return;
Navigator.of(
context,
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
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),
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.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',
),
],
),
],
),
),
);
}
// Only folders are valid Move/Copy destinations - files still show (so
// 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) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
);
}
Widget _buildGridCard(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
return Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
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 = files.applyFilesDisplayPrefs(_rawItems);
final invalidDestination = _destinationIsInvalid();
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
),
],
],
),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
),
),
if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (files.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(items[index]);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(items[index]);
}, childCount: items.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
];
return PopScope(
canPop: _pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _navigateUp();
},
child: Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
),
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invalidDestination) ...[
Text(
"Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders",
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 8),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: invalidDestination || _isSubmitting
? null
: () => _confirm(ops),
icon: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
widget.copy
? Icons.copy_rounded
: Icons.drive_file_move_rounded,
),
label: Text(
'${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} '
'item(s) to $currentLabel',
),
),
),
],
),
),
),
),
),
);
}
}
+283 -144
View File
@@ -1,14 +1,16 @@
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../models/selection_action.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/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/profile_avatar_button.dart';
@@ -19,6 +21,7 @@ import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
class PhotosView extends StatefulWidget { class PhotosView extends StatefulWidget {
final ScrollController scrollController; final ScrollController scrollController;
@@ -29,29 +32,123 @@ class PhotosView extends StatefulWidget {
State<PhotosView> createState() => _PhotosViewState(); State<PhotosView> createState() => _PhotosViewState();
} }
class _PhotosViewState extends State<PhotosView> { class _PhotosViewState extends State<PhotosView>
with SingleTickerProviderStateMixin {
bool _requested = false; bool _requested = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); setState(_selectedIds.clear);
} }
/// A one-shot hint that the selection actions row actually scrolls -
/// mirrors `FilesView`'s identical controls-row hint (see its doc
/// comment): nudges it right and back, once, the first time a selection
/// starts. No-ops if there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_selectionActionsScrollController.dispose();
super.dispose();
}
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
/// just the matching/toggle/immediate-confirm branch.
void _handlePickTap(
BuildContext context,
PickController pick,
NextcloudItem item,
) {
if (!pick.itemMatchesPickFilter(item)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("This app can't accept this file type"),
behavior: SnackBarBehavior.floating,
),
);
return;
}
if (pick.pickRequest!.allowMultiple) {
_toggleSelection(item);
} else {
pick.confirmPick([item]);
}
}
/// The bulk actions shown in the sticky selection toolbar for the /// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items. /// currently-selected items.
List<SelectionAction> _buildSelectionActions( List<SelectionAction> _buildSelectionActions(
ServerProvider provider, PickController pick,
List<NextcloudItem> selected, List<NextcloudItem> selected,
) { ) {
if (pick.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => pick.confirmPick(selected),
),
];
}
return [ return [
SelectionAction( SelectionAction(
icon: selected.every((i) => i.isFavorite) icon: selected.every((i) => i.isFavorite)
@@ -60,26 +157,34 @@ class _PhotosViewState extends State<PhotosView> {
label: selected.every((i) => i.isFavorite) label: selected.every((i) => i.isFavorite)
? 'Remove from favorites' ? 'Remove from favorites'
: 'Favorite', : 'Favorite',
color: Colors.red.shade400, onTap: () => _favoriteSelected(selected),
onTap: () => _favoriteSelected(provider, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.share_rounded, icon: Icons.share_rounded,
label: 'Share', label: 'Share',
onTap: () => selected.length == 1 onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single) ? ShareSheet.show(context, selected.single)
: _shareSelected(context, provider, selected), : _shareSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.download_rounded, icon: Icons.download_rounded,
label: 'Download', label: 'Download',
onTap: () => _downloadSelected(context, provider, selected), onTap: () => _downloadSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, icon: Icons.delete_outline_rounded,
label: 'Delete', label: 'Delete',
color: Theme.of(context).colorScheme.error, onTap: () => _confirmDeleteSelected(context, selected),
onTap: () => _confirmDeleteSelected(context, provider, selected), ),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false),
), ),
if (selected.length == 1) if (selected.length == 1)
SelectionAction( SelectionAction(
@@ -94,16 +199,18 @@ class _PhotosViewState extends State<PhotosView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; 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) { if (!_requested) {
_requested = true; _requested = true;
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchAllMedia(), (_) => photosController.fetchAllMedia(),
); );
} }
final photos = provider.photoItems; final photos = photosController.items;
final selectedItems = photos final selectedItems = photos
.where((i) => _selectedIds.contains(i.id)) .where((i) => _selectedIds.contains(i.id))
.toList(); .toList();
@@ -116,38 +223,38 @@ class _PhotosViewState extends State<PhotosView> {
children: [ children: [
IconButton( IconButton(
icon: Icon( icon: Icon(
provider.photosSortAscending photosController.sortAscending
? Icons.arrow_upward_rounded ? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded, : Icons.arrow_downward_rounded,
size: 20, size: 20,
), ),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tooltip: provider.photosSortAscending tooltip: photosController.sortAscending
? 'Ascending' ? 'Ascending'
: 'Descending', : 'Descending',
onPressed: provider.togglePhotosSortOrder, onPressed: photosController.toggleSortOrder,
), ),
Expanded( Expanded(
child: SortMenuButton( child: SortMenuButton(
field: provider.photosSortField, field: photosController.sortField,
onChanged: provider.setPhotosSortField, onChanged: photosController.setSortField,
), ),
), ),
ToggleIconButton( ToggleIconButton(
icon: provider.showFavoritesOnlyPhotos icon: photosController.showFavoritesOnly
? Icons.favorite_rounded ? Icons.favorite_rounded
: Icons.favorite_border_rounded, : Icons.favorite_border_rounded,
isSelected: provider.showFavoritesOnlyPhotos, isSelected: photosController.showFavoritesOnly,
onTap: provider.toggleFavoritesFilterPhotos, onTap: photosController.toggleFavoritesFilter,
tooltip: 'Favorites only', tooltip: 'Favorites only',
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
ToggleIconButton( ToggleIconButton(
icon: provider.showHiddenPhotos icon: photosController.showHidden
? Icons.visibility_rounded ? Icons.visibility_rounded
: Icons.visibility_off_rounded, : Icons.visibility_off_rounded,
isSelected: provider.showHiddenPhotos, isSelected: photosController.showHidden,
onTap: () => provider.toggleShowHiddenPhotos(), onTap: () => photosController.toggleShowHidden(),
tooltip: 'Show hidden files', tooltip: 'Show hidden files',
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
@@ -155,14 +262,17 @@ class _PhotosViewState extends State<PhotosView> {
children: [ children: [
ToggleIconButton( ToggleIconButton(
icon: Symbols.circles_rounded, icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud, isSelected: filesController.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud), onTap: () =>
filesController.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage', tooltip: 'Cloud storage',
), ),
ToggleIconButton( ToggleIconButton(
icon: Symbols.hard_drive_rounded, icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external, isSelected:
onTap: () => provider.setStorageScope(StorageScope.external), filesController.storageScope == StorageScope.external,
onTap: () =>
filesController.setStorageScope(StorageScope.external),
tooltip: 'External storage', tooltip: 'External storage',
), ),
], ],
@@ -173,69 +283,21 @@ class _PhotosViewState extends State<PhotosView> {
); );
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the toolbar below takes over // Sticky while browsing; once selecting, the selection bar takes over
// the pinned slot and this is free to scroll away. // the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
SliverPersistentHeader( SliverPersistentHeader(
pinned: !_isSelecting, pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow), delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
), ),
if (_isSelecting)
SliverAppBar(
pinned: true,
// This isn't the scroll view's topmost sliver - the Synced
// header above it already reserves status-bar space, so leaving
// this at its default `primary: true` double-reserves it too,
// showing up as a big empty gap above the toolbar.
primary: false,
automaticallyImplyLeading: false,
toolbarHeight: 48,
// No leading/actions slots - everything lives in `title`, wrapped
// in the exact same 16px horizontal Padding as the controls row
// above it, so the two rows' content lines up edge-to-edge
// instead of relying on SliverAppBar's own leadingWidth/actions
// insets (which don't match the controls row's).
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20, color: action.color),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)), const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isMediaLoading && photos.isEmpty) if (photosController.isLoading && photos.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
) )
else if (provider.mediaErrorMessage != null) else if (photosController.errorMessage != null)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center( child: Center(
@@ -259,7 +321,7 @@ class _PhotosViewState extends State<PhotosView> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
provider.mediaErrorMessage!, photosController.errorMessage!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -267,7 +329,7 @@ class _PhotosViewState extends State<PhotosView> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( FilledButton.icon(
onPressed: provider.fetchAllMedia, onPressed: photosController.fetchAllMedia,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'), label: const Text('Retry'),
), ),
@@ -311,7 +373,7 @@ class _PhotosViewState extends State<PhotosView> {
), ),
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final photo = photos[index]; final photo = photos[index];
return _buildPhotoTile(context, photo, provider); return _buildPhotoTile(context, photo);
}, childCount: photos.length), }, childCount: photos.length),
), ),
), ),
@@ -332,20 +394,87 @@ class _PhotosViewState extends State<PhotosView> {
}, },
child: SyncedHeaderScaffold( child: SyncedHeaderScaffold(
scrollController: widget.scrollController, scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()], actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllMedia, onRefresh: photosController.fetchAllMedia,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, pick, selectedItems)
: null,
contentSlivers: contentSlivers, contentSlivers: contentSlivers,
), ),
); );
} }
Widget _buildPhotoTile( /// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N
/// selected" count, and the horizontally-scrollable bulk actions.
Widget _buildSelectionBar(
BuildContext context, BuildContext context,
NextcloudItem photo, ThemeData theme,
ServerProvider provider, PickController pick,
List<NextcloudItem> selectedItems,
) { ) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
// Left-aligned (not anchored to the trailing edge) so the
// first action's left edge sits at a fixed spot - lining up
// with the controls row's own first icon directly below it -
// regardless of how many actions there are.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
pick,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
);
}
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final pick = context.watch<PickController>();
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(photo.id); final isSelected = _selectedIds.contains(photo.id);
// No border radius on the tile itself — the outer ClipRRect below is // No border radius on the tile itself — the outer ClipRRect below is
@@ -353,13 +482,17 @@ class _PhotosViewState extends State<PhotosView> {
// floating rounded corner mid-drag (see the same fix in files_view). // floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector( final tile = GestureDetector(
onTap: () { onTap: () {
if (_isSelecting) { if (pick.isPicking) {
_handlePickTap(context, pick, photo);
} else if (_isSelecting) {
_toggleSelection(photo); _toggleSelection(photo);
} else { } else {
_openLightbox(context, photo, provider); _openLightbox(context, photo);
} }
}, },
onLongPress: () => _toggleSelection(photo), onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: ColoredBox( child: ColoredBox(
color: colorScheme.surfaceContainerHigh, color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail( child: SelectableThumbnail(
@@ -380,7 +513,7 @@ class _PhotosViewState extends State<PhotosView> {
.round(); .round();
return Image.network( return Image.network(
photo.previewUrl!, photo.previewUrl!,
headers: provider.service?.authHeaders, headers: session.service?.authHeaders,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: cachePixels, cacheWidth: cachePixels,
cacheHeight: cachePixels, cacheHeight: cachePixels,
@@ -444,25 +577,20 @@ class _PhotosViewState extends State<PhotosView> {
); );
} }
void _openLightbox( void _openLightbox(BuildContext context, NextcloudItem photo) {
BuildContext context, final photos = context.read<PhotosController>().items;
NextcloudItem photo,
ServerProvider provider,
) {
Navigator.push( Navigator.push(
context, context,
FileViewerScreen.route(item: photo, siblings: provider.photoItems), FileViewerScreen.route(item: photo, siblings: photos),
); );
} }
Future<void> _favoriteSelected( Future<void> _favoriteSelected(List<NextcloudItem> items) async {
ServerProvider provider, final ops = context.read<ItemOperations>();
List<NextcloudItem> items,
) async {
final allFavorited = items.every((i) => i.isFavorite); final allFavorited = items.every((i) => i.isFavorite);
for (final item in items) { for (final item in items) {
if (item.isFavorite == allFavorited) { if (item.isFavorite == allFavorited) {
await provider.toggleItemFavorite(item); await ops.toggleItemFavorite(item);
} }
} }
_clearSelection(); _clearSelection();
@@ -470,9 +598,9 @@ class _PhotosViewState extends State<PhotosView> {
Future<void> _shareSelected( Future<void> _shareSelected(
BuildContext context, BuildContext context,
ServerProvider provider,
List<NextcloudItem> items, List<NextcloudItem> items,
) async { ) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar( const SnackBar(
@@ -483,7 +611,7 @@ class _PhotosViewState extends State<PhotosView> {
final lines = <String>[]; final lines = <String>[];
for (final item in items) { for (final item in items) {
final link = await provider.createShareLink(item); final link = await ops.createShareLink(item);
if (link != null) { if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link); lines.add(items.length > 1 ? '${item.name}: $link' : link);
} }
@@ -515,50 +643,39 @@ class _PhotosViewState extends State<PhotosView> {
} }
} }
/// Hands the whole batch off to `DownloadService.kt` - see
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected( Future<void> _downloadSelected(
BuildContext context, BuildContext context,
ServerProvider provider,
List<NextcloudItem> items, List<NextcloudItem> items,
) async { ) async {
final messenger = ScaffoldMessenger.of(context); final session = context.read<SessionController>();
messenger.showSnackBar(
SnackBar(
content: Text('Downloading ${items.length} item(s)...'),
behavior: SnackBarBehavior.floating,
),
);
var succeeded = 0;
for (final item in items) {
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, item.name);
await provider.service!.downloadToFile(item.path, tempPath);
final ext = p.extension(item.name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(item.name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
);
succeeded++;
} catch (_) {
// Reported in the summary snackbar below.
}
}
_clearSelection(); _clearSelection();
messenger.showSnackBar( final messenger = ScaffoldMessenger.of(context);
SnackBar( try {
content: Text('Downloaded $succeeded of ${items.length} item(s)'), await DownloadService.startDownload(session, items);
behavior: SnackBarBehavior.floating, messenger.showSnackBar(
), SnackBar(
); content: Text(
items.length == 1
? 'Downloading ${items.first.name} - see the notification for progress'
: 'Downloading ${items.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start download: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
} }
Future<void> _confirmDeleteSelected( Future<void> _confirmDeleteSelected(
BuildContext context, BuildContext context,
ServerProvider provider,
List<NextcloudItem> items, List<NextcloudItem> items,
) async { ) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -587,10 +704,11 @@ class _PhotosViewState extends State<PhotosView> {
); );
if (confirmed != true || !context.mounted) return; if (confirmed != true || !context.mounted) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
var succeeded = 0; var succeeded = 0;
for (final item in items) { for (final item in items) {
final success = await provider.deleteItem(item.path); final success = await ops.deleteItem(item.path);
if (success) succeeded++; if (success) succeeded++;
} }
@@ -602,4 +720,25 @@ class _PhotosViewState extends State<PhotosView> {
), ),
); );
} }
Future<void> _moveOrCopySelected(
List<NextcloudItem> items, {
required bool copy,
}) async {
final result = await MoveCopyDestinationPicker.show(
context,
items,
copy: copy,
);
if (!mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${copy ? 'Copied' : 'Moved'} ${result.succeeded} of ${items.length} item(s)',
),
behavior: SnackBarBehavior.floating,
),
);
}
} }
+16 -26
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart'; import '../providers/recent_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.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 /// Recently modified files across the whole account (not folders), newest
/// first — see [ServerProvider.fetchRecent]. /// first — see [RecentController.fetchAll].
class RecentView extends StatefulWidget { class RecentView extends StatefulWidget {
final ScrollController scrollController; final ScrollController scrollController;
@@ -65,26 +65,24 @@ class _RecentViewState extends State<RecentView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final recent = context.watch<RecentController>();
if (!_requested) { if (!_requested) {
_requested = true; _requested = true;
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback((_) => recent.fetchAll());
(_) => provider.fetchRecent(),
);
} }
final items = provider.recentItems; final items = recent.items;
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isRecentLoading && items.isEmpty) if (recent.isLoading && items.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
) )
else if (provider.recentErrorMessage != null) else if (recent.errorMessage != null)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center( child: Center(
@@ -108,7 +106,7 @@ class _RecentViewState extends State<RecentView> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
provider.recentErrorMessage!, recent.errorMessage!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -116,7 +114,7 @@ class _RecentViewState extends State<RecentView> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( FilledButton.icon(
onPressed: provider.fetchRecent, onPressed: recent.fetchAll,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'), label: const Text('Retry'),
), ),
@@ -154,7 +152,7 @@ class _RecentViewState extends State<RecentView> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index]; final item = items[index];
return _buildTile(context, item, provider); return _buildTile(context, item);
}, childCount: items.length), }, childCount: items.length),
), ),
), ),
@@ -165,18 +163,13 @@ class _RecentViewState extends State<RecentView> {
return SyncedHeaderScaffold( return SyncedHeaderScaffold(
scrollController: widget.scrollController, scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()], actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchRecent, onRefresh: recent.fetchAll,
contentSlivers: contentSlivers, contentSlivers: contentSlivers,
); );
} }
Widget _buildTile( Widget _buildTile(BuildContext context, NextcloudItem item) {
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final iconColor = _recentItemIconColor(context, item.type); final iconColor = _recentItemIconColor(context, item.type);
@@ -192,7 +185,7 @@ class _RecentViewState extends State<RecentView> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () => _openFile(context, item, provider), onTap: () => _openFile(context, item),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
@@ -248,12 +241,9 @@ class _RecentViewState extends State<RecentView> {
); );
} }
void _openFile( void _openFile(BuildContext context, NextcloudItem item) {
BuildContext context, final recent = context.read<RecentController>();
NextcloudItem item, final siblings = recent.items.where((i) => i.isMedia).toList();
ServerProvider provider,
) {
final siblings = provider.recentItems.where((i) => i.isMedia).toList();
Navigator.push( Navigator.push(
context, context,
FileViewerScreen.route(item: item, siblings: siblings), 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 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/nextcloud_item.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'; import 'file_viewer_screen.dart';
class SearchView extends StatefulWidget { class SearchView extends StatefulWidget {
@@ -50,13 +51,13 @@ class _SearchViewState extends State<SearchView> {
} }
Future<void> _runSearch(String query) async { Future<void> _runSearch(String query) async {
final provider = context.read<ServerProvider>(); final files = context.read<FilesController>();
setState(() { setState(() {
_isSearching = true; _isSearching = true;
_error = null; _error = null;
}); });
try { try {
final results = await provider.searchFiles(query); final results = await files.searchFiles(query);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_results = results; _results = results;
@@ -98,12 +99,13 @@ class _SearchViewState extends State<SearchView> {
/// browsing before), switches the shell to the Files tab, and closes /// browsing before), switches the shell to the Files tab, and closes
/// search. For a file, also opens it once back on Files. /// search. For a file, also opens it once back on Files.
Future<void> _openResult(NextcloudItem item) async { 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 navigator = Navigator.of(context);
final folderPath = item.isFolder ? item.path : p.dirname(item.path); final folderPath = item.isFolder ? item.path : p.dirname(item.path);
await provider.navigateToAbsoluteFolder(folderPath); await files.navigateToAbsoluteFolder(folderPath);
provider.requestTab(AppTab.files); settings.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst);
if (!item.isFolder) { if (!item.isFolder) {
+217 -119
View File
@@ -4,14 +4,19 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/nextcloud_item.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/share_intent_service.dart';
import '../services/upload_service.dart'; import '../services/upload_service.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart'; import '../widgets/item_icon.dart';
import '../widgets/marquee_title.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart'; import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart'; import '../widgets/sort_menu_button.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
/// Shown when another app shares one or more files to Noo (Android's /// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder, /// "Share to..." sheet). Lets the user browse to a destination folder,
@@ -32,21 +37,31 @@ class ShareUploadView extends StatefulWidget {
} }
class _ShareUploadViewState extends State<ShareUploadView> { class _ShareUploadViewState extends State<ShareUploadView> {
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Shared files have no relationship to wherever the user was last // Shared files have no relationship to wherever the user was last
// browsing, so start the destination picker fresh at the root. // browsing, so start the destination picker fresh at the root.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<ServerProvider>().navigateToAbsoluteFolder('/'); if (mounted) context.read<FilesController>().navigateToAbsoluteFolder('/');
}); });
} }
Future<void> _uploadHere(ServerProvider provider) async { @override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _uploadHere(SessionController session) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
try { try {
await UploadService.startUpload(provider, widget.files); await UploadService.startUpload(session, files, widget.files);
} catch (e) { } catch (e) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
@@ -57,7 +72,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
return; return;
} }
provider.requestTab(AppTab.files); settings.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst);
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
@@ -71,7 +86,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
); );
} }
Widget _buildControlsRow(ServerProvider provider) { Widget _buildControlsRow(FilesController files) {
return SizedBox( return SizedBox(
height: 44, height: 44,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -80,37 +95,28 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [ children: [
IconButton( IconButton(
icon: Icon( icon: Icon(
provider.filesSortAscending files.filesSortAscending
? Icons.arrow_upward_rounded ? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded, : Icons.arrow_downward_rounded,
size: 20, size: 20,
), ),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending', tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleFilesSortOrder, onPressed: files.toggleFilesSortOrder,
), ),
SizedBox( SizedBox(
width: 130, width: 130,
child: SortMenuButton( child: SortMenuButton(
field: provider.filesSortField, field: files.filesSortField,
onChanged: provider.setFilesSortField, onChanged: files.setFilesSortField,
), ),
), ),
ToggleIconButton( ToggleIconButton(
icon: provider.showFavoritesOnlyFiles icon: files.showHiddenFiles
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: provider.showFavoritesOnlyFiles,
onTap: provider.toggleFavoritesFilterFiles,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: provider.showHiddenFiles
? Icons.visibility_rounded ? Icons.visibility_rounded
: Icons.visibility_off_rounded, : Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles, isSelected: files.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(), onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files', tooltip: 'Show hidden files',
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
@@ -118,14 +124,14 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [ children: [
ToggleIconButton( ToggleIconButton(
icon: Symbols.circles_rounded, icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud, isSelected: files.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud), onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage', tooltip: 'Cloud storage',
), ),
ToggleIconButton( ToggleIconButton(
icon: Symbols.hard_drive_rounded, icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external, isSelected: files.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external), onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage', tooltip: 'External storage',
), ),
], ],
@@ -135,24 +141,24 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [ children: [
ToggleIconButton( ToggleIconButton(
icon: Icons.select_all_rounded, icon: Icons.select_all_rounded,
isSelected: provider.filesTypeFilter == FilesTypeFilter.all, isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => provider.setFilesTypeFilter(FilesTypeFilter.all), onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders', tooltip: 'Files & folders',
), ),
ToggleIconButton( ToggleIconButton(
icon: Icons.insert_drive_file_outlined, icon: Icons.insert_drive_file_outlined,
isSelected: isSelected:
provider.filesTypeFilter == FilesTypeFilter.filesOnly, files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () => onTap: () =>
provider.setFilesTypeFilter(FilesTypeFilter.filesOnly), files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only', tooltip: 'Files only',
), ),
ToggleIconButton( ToggleIconButton(
icon: Icons.folder_outlined, icon: Icons.folder_outlined,
isSelected: isSelected:
provider.filesTypeFilter == FilesTypeFilter.foldersOnly, files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () => onTap: () =>
provider.setFilesTypeFilter(FilesTypeFilter.foldersOnly), files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only', tooltip: 'Folders only',
), ),
], ],
@@ -162,14 +168,14 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [ children: [
ToggleIconButton( ToggleIconButton(
icon: Icons.view_list_rounded, icon: Icons.view_list_rounded,
isSelected: !provider.isGridView, isSelected: !files.isGridView,
onTap: () => provider.setGridView(false), onTap: () => files.setGridView(false),
tooltip: 'List view', tooltip: 'List view',
), ),
ToggleIconButton( ToggleIconButton(
icon: Icons.grid_view_rounded, icon: Icons.grid_view_rounded,
isSelected: provider.isGridView, isSelected: files.isGridView,
onTap: () => provider.setGridView(true), onTap: () => files.setGridView(true),
tooltip: 'Grid view', tooltip: 'Grid view',
), ),
], ],
@@ -183,13 +189,11 @@ class _ShareUploadViewState extends State<ShareUploadView> {
// Only folders are valid upload destinations - files still show (so the // Only folders are valid upload destinations - files still show (so the
// listing matches what the Files tab itself would show for this folder) // listing matches what the Files tab itself would show for this folder)
// but are visually dimmed and inert rather than hidden outright. // but are visually dimmed and inert rather than hidden outright.
Widget _buildListTile( Widget _buildListTile(BuildContext context, NextcloudItem item) {
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final files = context.read<FilesController>();
final session = context.watch<SessionController>();
final isFolder = item.isFolder; final isFolder = item.isFolder;
return Padding( return Padding(
@@ -201,9 +205,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
child: Material( child: Material(
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
child: InkWell( child: InkWell(
onTap: isFolder onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
? () => provider.navigateToFolder(item.path)
: null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
@@ -213,7 +215,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [ children: [
ItemThumbnail( ItemThumbnail(
item: item, item: item,
provider: provider, service: session.service,
size: 44, size: 44,
borderRadius: 12, borderRadius: 12,
iconSize: 22, iconSize: 22,
@@ -253,13 +255,10 @@ class _ShareUploadViewState extends State<ShareUploadView> {
); );
} }
Widget _buildGridCard( Widget _buildGridCard(BuildContext context, NextcloudItem item) {
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final files = context.read<FilesController>();
final isFolder = item.isFolder; final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type); final iconColor = getIconColor(context, item.type);
@@ -270,7 +269,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: isFolder ? () => provider.navigateToFolder(item.path) : null, onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding( child: Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
child: Column( child: Column(
@@ -324,82 +323,181 @@ class _ShareUploadViewState extends State<ShareUploadView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final files = context.watch<FilesController>();
final hasBreadcrumbs = provider.pathStack.length > 1; final session = context.watch<SessionController>();
final currentPath = provider.pathStack.last; final hasBreadcrumbs = files.pathStack.length > 1;
final currentPath = files.pathStack.last;
final currentLabel = currentPath == '/' final currentLabel = currentPath == '/'
? 'Home' ? 'Home'
: currentPath.split('/').where((s) => s.isNotEmpty).last; : currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = provider.items; final items = files.items;
return Scaffold( // Mirrors FilesView's own controls-row + breadcrumbs sticky header
appBar: AppBar( // exactly (padding, heights) so this reads as the same browser, just
title: Text( // reached from a share intent instead of the Files tab.
widget.files.length == 1 final controlsColumn = Padding(
? 'Upload ${widget.files.first.name}' padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
: 'Upload ${widget.files.length} files', child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
),
body: Column(
children: [ children: [
Padding( _buildControlsRow(files),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), if (hasBreadcrumbs) ...[
child: _buildControlsRow(provider), const SizedBox(height: 10),
), SizedBox(
if (hasBreadcrumbs) height: 32,
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Breadcrumbs( child: Breadcrumbs(
pathStack: provider.pathStack, pathStack: files.pathStack,
onTap: (index) => provider.navigateToPathIndex(index), onTap: (index) => files.navigateToPathIndex(index),
), ),
), ),
Expanded( ],
child: provider.isLoading
? const Center(child: CircularProgressIndicator())
: items.isEmpty
? Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
)
: provider.isGridView
? GridView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: items.length,
itemBuilder: (context, index) =>
_buildGridCard(context, items[index], provider),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
itemCount: items.length,
itemBuilder: (context, index) =>
_buildListTile(context, items[index], provider),
),
),
], ],
), ),
bottomNavigationBar: SafeArea( );
child: Padding(
padding: const EdgeInsets.all(16), final contentSlivers = <Widget>[
child: FilledButton.icon( SliverPersistentHeader(
onPressed: () => _uploadHere(provider), pinned: true,
icon: const Icon(Icons.upload_rounded), delegate: StickyHeaderDelegate(
label: Text('Upload to $currentLabel'), height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
),
),
if (files.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (files.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, items[index]);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, items[index]);
}, childCount: items.length),
),
),
// So the last row isn't hidden behind the bottom "Upload to..." bar.
const SliverToBoxAdapter(child: SizedBox(height: 24)),
];
return Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
// 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
// top chrome is identical to every other tab minus that one entry.
// Backing out is still the system back gesture/button, same as any
// other pushed screen.
actions: const [ProfileAvatarButton()],
contentSlivers: contentSlivers,
),
// A rounded-top, elevated bar "peeking" up from the bottom edge - the
// uploading-file summary (marqueed if it doesn't fit on one line)
// sits directly above the destination button, both inside the one
// sheet, rather than the summary living up in the scrolling content
// far away from the action it describes.
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Same pill/chip look and exact same duller background
// color as the filter toggles' shared background
// (SegmentedIconGroup's `surfaceContainerHigh`), so it reads
// as part of the same visual language rather than a new
// accent color. Sized to the text itself (like a real chip)
// up to the row's available width - MarqueeTitle needs a
// concrete (not just loose) width to know whether/how far
// to scroll, so this measures the text once up front rather
// than leaving the chip unconstrained.
LayoutBuilder(
builder: (context, constraints) {
final uploadingText = widget.files.length == 1
? 'Uploading ${widget.files.first.name}'
: 'Uploading ${widget.files.length} files';
final chipTextStyle = theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
);
const horizontalPadding = 28.0;
final painter = TextPainter(
text: TextSpan(text: uploadingText, style: chipTextStyle),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
final chipWidth = (painter.width + horizontalPadding).clamp(
0.0,
constraints.maxWidth,
);
return Container(
width: chipWidth,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: SizedBox(
height: 20,
child: MarqueeTitle(
text: uploadingText,
style: chipTextStyle,
),
),
);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
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 'package:provider/provider.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart'; import '../models/nextcloud_share.dart';
import '../providers/server_provider.dart'; import '../providers/shares_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/synced_header_scaffold.dart';
@@ -60,16 +60,16 @@ class _SharesViewState extends State<SharesView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final sharesController = context.watch<SharesController>();
if (!_requested) { if (!_requested) {
_requested = true; _requested = true;
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchShares(), (_) => sharesController.fetchAll(),
); );
} }
final shares = provider.shares; final shares = sharesController.shares;
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -80,19 +80,20 @@ class _SharesViewState extends State<SharesView> {
ButtonSegment(value: false, label: Text('Shared by me')), ButtonSegment(value: false, label: Text('Shared by me')),
ButtonSegment(value: true, label: Text('Shared with me')), ButtonSegment(value: true, label: Text('Shared with me')),
], ],
selected: {provider.sharesWithMe}, selected: {sharesController.sharedWithMe},
onSelectionChanged: (set) => provider.setSharesWithMe(set.first), onSelectionChanged: (set) =>
sharesController.setSharedWithMe(set.first),
), ),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 8)), const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isSharesLoading && shares.isEmpty) if (sharesController.isLoading && shares.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
) )
else if (provider.sharesErrorMessage != null) else if (sharesController.errorMessage != null)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center( child: Center(
@@ -116,7 +117,7 @@ class _SharesViewState extends State<SharesView> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
provider.sharesErrorMessage!, sharesController.errorMessage!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -124,7 +125,7 @@ class _SharesViewState extends State<SharesView> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( FilledButton.icon(
onPressed: provider.fetchShares, onPressed: sharesController.fetchAll,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'), label: const Text('Retry'),
), ),
@@ -147,7 +148,7 @@ class _SharesViewState extends State<SharesView> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
provider.sharesWithMe sharesController.sharedWithMe
? 'Nothing has been shared with you' ? 'Nothing has been shared with you'
: 'You haven\'t shared anything yet', : 'You haven\'t shared anything yet',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
@@ -164,7 +165,7 @@ class _SharesViewState extends State<SharesView> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index]; final share = shares[index];
return _buildShareTile(context, share, provider); return _buildShareTile(context, share);
}, childCount: shares.length), }, childCount: shares.length),
), ),
), ),
@@ -175,18 +176,13 @@ class _SharesViewState extends State<SharesView> {
return SyncedHeaderScaffold( return SyncedHeaderScaffold(
scrollController: widget.scrollController, scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()], actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchShares, onRefresh: sharesController.fetchAll,
contentSlivers: contentSlivers, contentSlivers: contentSlivers,
); );
} }
Widget _buildShareTile( Widget _buildShareTile(BuildContext context, NextcloudShare share) {
BuildContext context,
NextcloudShare share,
ServerProvider provider,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final iconColor = colorScheme.primary; final iconColor = colorScheme.primary;
@@ -260,7 +256,7 @@ class _SharesViewState extends State<SharesView> {
IconButton( IconButton(
icon: Icon(Icons.link_off_rounded, color: colorScheme.error), icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare', 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( Future<void> _confirmUnshare(
BuildContext context, BuildContext context,
ServerProvider provider,
NextcloudShare share, NextcloudShare share,
) async { ) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -303,7 +298,9 @@ class _SharesViewState extends State<SharesView> {
if (confirmed != true || !context.mounted) return; if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share); final success = await context.read<SharesController>().deleteShare(
share,
);
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
+16 -26
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart'; import '../providers/trash_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/synced_header_scaffold.dart';
@@ -42,26 +42,26 @@ class _TrashViewState extends State<TrashView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final trashController = context.watch<TrashController>();
if (!_requested) { if (!_requested) {
_requested = true; _requested = true;
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchTrash(), (_) => trashController.fetchAll(),
); );
} }
final trash = provider.trashItems; final trash = trashController.items;
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isTrashLoading && trash.isEmpty) if (trashController.isLoading && trash.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
) )
else if (provider.trashErrorMessage != null) else if (trashController.errorMessage != null)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center( child: Center(
@@ -85,7 +85,7 @@ class _TrashViewState extends State<TrashView> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
provider.trashErrorMessage!, trashController.errorMessage!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -93,7 +93,7 @@ class _TrashViewState extends State<TrashView> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( FilledButton.icon(
onPressed: provider.fetchTrash, onPressed: trashController.fetchAll,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'), label: const Text('Retry'),
), ),
@@ -131,7 +131,7 @@ class _TrashViewState extends State<TrashView> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index]; final item = trash[index];
return _buildTrashTile(context, item, provider); return _buildTrashTile(context, item);
}, childCount: trash.length), }, childCount: trash.length),
), ),
), ),
@@ -142,18 +142,13 @@ class _TrashViewState extends State<TrashView> {
return SyncedHeaderScaffold( return SyncedHeaderScaffold(
scrollController: widget.scrollController, scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()], actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchTrash, onRefresh: trashController.fetchAll,
contentSlivers: contentSlivers, contentSlivers: contentSlivers,
); );
} }
Widget _buildTrashTile( Widget _buildTrashTile(BuildContext context, NextcloudItem item) {
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final iconColor = colorScheme.outline; final iconColor = colorScheme.outline;
@@ -210,7 +205,7 @@ class _TrashViewState extends State<TrashView> {
icon: const Icon(Icons.restore_rounded), icon: const Icon(Icons.restore_rounded),
tooltip: 'Restore', tooltip: 'Restore',
color: colorScheme.primary, color: colorScheme.primary,
onPressed: () => _restore(context, provider, item), onPressed: () => _restore(context, item),
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
@@ -218,7 +213,7 @@ class _TrashViewState extends State<TrashView> {
color: colorScheme.error, color: colorScheme.error,
), ),
tooltip: 'Delete forever', tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, provider, item), onPressed: () => _confirmDeleteForever(context, item),
), ),
], ],
), ),
@@ -227,13 +222,9 @@ class _TrashViewState extends State<TrashView> {
); );
} }
Future<void> _restore( Future<void> _restore(BuildContext context, NextcloudItem item) async {
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await provider.restoreTrashItem(item); final success = await context.read<TrashController>().restore(item);
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@@ -246,7 +237,6 @@ class _TrashViewState extends State<TrashView> {
Future<void> _confirmDeleteForever( Future<void> _confirmDeleteForever(
BuildContext context, BuildContext context,
ServerProvider provider,
NextcloudItem item, NextcloudItem item,
) async { ) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -276,7 +266,7 @@ class _TrashViewState extends State<TrashView> {
if (confirmed != true || !context.mounted) return; if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteTrashItemForever(item); final success = await context.read<TrashController>().deleteForever(item);
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
+3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../views/activity_view.dart'; import '../views/activity_view.dart';
import '../views/favorites_view.dart';
import '../views/files_view.dart'; import '../views/files_view.dart';
import '../views/photos_view.dart'; import '../views/photos_view.dart';
import '../views/recent_view.dart'; import '../views/recent_view.dart';
@@ -16,6 +17,8 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
return FilesView(scrollController: controller); return FilesView(scrollController: controller);
case AppTab.photos: case AppTab.photos:
return PhotosView(scrollController: controller); return PhotosView(scrollController: controller);
case AppTab.favorites:
return FavoritesView(scrollController: controller);
case AppTab.activity: case AppTab.activity:
return ActivityView(scrollController: controller); return ActivityView(scrollController: controller);
case AppTab.trash: case AppTab.trash:
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/nextcloud_item.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 /// Per-file activity feed — same list-item look as the account-wide
/// Activity tab (activity_view.dart), scoped to a single item. /// Activity tab (activity_view.dart), scoped to a single item.
@@ -21,8 +21,8 @@ class _DetailsActivityTabState extends State<DetailsActivityTab> {
List<NextcloudActivity> _activities = []; List<NextcloudActivity> _activities = [];
Future<void> _load() async { Future<void> _load() async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final result = await provider.fetchFileActivity(widget.item); final result = await ops.fetchFileActivity(widget.item);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_activities = result; _activities = result;
+32 -7
View File
@@ -51,11 +51,24 @@ class DetailsSheet extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
DetailsHeader(item: item), DetailsHeader(item: item),
// Icon size matches the bottom nav bar's own tab icons
// (FloatingBottomNavBar) rather than TabBar's oversized default,
// so a "tabs" affordance reads the same size everywhere in the
// app.
const TabBar( const TabBar(
tabs: [ tabs: [
Tab(icon: Icon(Icons.info_outline_rounded), text: 'Info'), Tab(
Tab(icon: Icon(Icons.history_rounded), text: 'Versions'), icon: Icon(Icons.info_outline_rounded, size: 22),
Tab(icon: Icon(Icons.electric_bolt_rounded), text: 'Activity'), text: 'Info',
),
Tab(
icon: Icon(Icons.history_rounded, size: 22),
text: 'Versions',
),
Tab(
icon: Icon(Icons.electric_bolt_rounded, size: 22),
text: 'Activity',
),
], ],
), ),
Expanded( Expanded(
@@ -73,13 +86,25 @@ class DetailsSheet extends StatelessWidget {
} }
} }
/// Icon/name/size/date row shared by [DetailsSheet] and the media viewer's /// Icon/name/size/date row shared by [DetailsSheet], the media viewer's
/// collapsed "peek" state (which shows just this, before the user drags the /// collapsed "peek" state (which shows just this, before the user drags the
/// sheet open far enough to reveal the tabs). /// sheet open far enough to reveal the tabs), and [ShareSheet]'s own title
/// (so every per-item bottom sheet opens on the same header instead of
/// [ShareSheet] alone using a bare title `Text`).
class DetailsHeader extends StatelessWidget { class DetailsHeader extends StatelessWidget {
final NextcloudItem item; final NextcloudItem item;
const DetailsHeader({super.key, required this.item}); /// Defaults to this widget's own standalone inset ([DetailsSheet]); a
/// caller whose surrounding scroll view already applies horizontal
/// padding (e.g. [ShareSheet]'s `ListView`) should pass a padding with
/// zero left/right to avoid doubling it up.
final EdgeInsetsGeometry padding;
const DetailsHeader({
super.key,
required this.item,
this.padding = const EdgeInsets.fromLTRB(20, 12, 20, 8),
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -87,7 +112,7 @@ class DetailsHeader extends StatelessWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), padding: padding,
child: Row( child: Row(
children: [ children: [
Container( Container(
@@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/nextcloud_file_version.dart'; import '../../models/nextcloud_file_version.dart';
import '../../models/nextcloud_item.dart'; import '../../models/nextcloud_item.dart';
import '../../providers/server_provider.dart'; import '../../providers/item_operations.dart';
import '../synced_header_scaffold.dart' show formatBytes; import '../synced_header_scaffold.dart' show formatBytes;
/// File version history — a synthetic "Current version" row (from the /// File version history — a synthetic "Current version" row (from the
@@ -27,8 +27,8 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
List<NextcloudFileVersion> _versions = []; List<NextcloudFileVersion> _versions = [];
Future<void> _load() async { Future<void> _load() async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final result = await provider.fetchFileVersions(widget.item); final result = await ops.fetchFileVersions(widget.item);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_versions = result; _versions = result;
@@ -37,9 +37,9 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
} }
Future<void> _restore(NextcloudFileVersion version) async { Future<void> _restore(NextcloudFileVersion version) async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await provider.restoreFileVersion( final success = await ops.restoreFileVersion(
widget.item, widget.item,
version.versionLabel, version.versionLabel,
); );
@@ -56,7 +56,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
} }
Future<void> _download(NextcloudFileVersion version) async { Future<void> _download(NextcloudFileVersion version) async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
@@ -64,7 +64,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
tempDir.path, tempDir.path,
'${version.versionLabel}_${widget.item.name}', '${version.versionLabel}_${widget.item.name}',
); );
final success = await provider.downloadVersion( final success = await ops.downloadVersion(
widget.item, widget.item,
version.versionLabel, version.versionLabel,
tempPath, tempPath,
+58 -47
View File
@@ -61,57 +61,68 @@ class FloatingBottomNavBar extends StatelessWidget {
blurSigma: blurSigma, blurSigma: blurSigma,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row( // The selected item's label expands the pill, and with enough
mainAxisSize: MainAxisSize.min, // tabs visible that can outgrow the available width (the
children: List.generate(items.length, (index) { // ConstrainedBox above caps it at 480, but the screen itself may
final item = items[index]; // be narrower) - wrapped in a scroll view rather than a plain Row
final isSelected = selectedIndex == index; // so it degrades to a swipe instead of overflowing/clipping.
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(items.length, (index) {
final item = items[index];
final isSelected = selectedIndex == index;
return InkWell( return InkWell(
onTap: () => onDestinationSelected(index), onTap: () => onDestinationSelected(index),
borderRadius: BorderRadius.circular(26), borderRadius: BorderRadius.circular(26),
splashColor: colorScheme.primary.withValues(alpha: 0.12), splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutCubic, curve: Curves.easeInOutCubic,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isSelected ? 16 : 12, horizontal: isSelected ? 16 : 12,
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
borderRadius: BorderRadius.circular(26), ? colorScheme.primary
), : Colors.transparent,
child: Row( borderRadius: BorderRadius.circular(26),
mainAxisSize: MainAxisSize.min, ),
children: [ child: Row(
Icon( mainAxisSize: MainAxisSize.min,
item.icon, children: [
size: 22, Icon(
color: isSelected item.icon,
? colorScheme.onPrimary size: 22,
: colorScheme.onSurfaceVariant, color: isSelected
), ? colorScheme.onPrimary
if (isSelected) ...[ : colorScheme.onSurfaceVariant,
const SizedBox(width: 8),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
letterSpacing: 0.1,
),
), ),
if (isSelected) ...[
const SizedBox(width: 8),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
letterSpacing: 0.1,
),
),
],
], ],
], ),
), ),
), );
); }),
}), ),
), ),
), ),
); );
+4 -4
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/nextcloud_item.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 /// 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 /// screen that lists [NextcloudItem]s the same way the Files tab does (see
@@ -51,7 +51,7 @@ Color getIconColor(BuildContext context, NextcloudItemType type) {
/// the Photos tab. /// the Photos tab.
class ItemThumbnail extends StatelessWidget { class ItemThumbnail extends StatelessWidget {
final NextcloudItem item; final NextcloudItem item;
final ServerProvider provider; final NextcloudService? service;
final double size; final double size;
final double borderRadius; final double borderRadius;
final double iconSize; final double iconSize;
@@ -59,7 +59,7 @@ class ItemThumbnail extends StatelessWidget {
const ItemThumbnail({ const ItemThumbnail({
super.key, super.key,
required this.item, required this.item,
required this.provider, required this.service,
required this.size, required this.size,
required this.borderRadius, required this.borderRadius,
required this.iconSize, required this.iconSize,
@@ -92,7 +92,7 @@ class ItemThumbnail extends StatelessWidget {
children: [ children: [
Image.network( Image.network(
item.previewUrl!, item.previewUrl!,
headers: provider.service?.authHeaders, headers: service?.authHeaders,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: cachePixels, cacheWidth: cachePixels,
cacheHeight: cachePixels, cacheHeight: cachePixels,
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:marquee/marquee.dart';
/// A plain, single-line ellipsized [Text] for content that fits, or an
/// auto-scrolling [Marquee] for text too long for the available width -
/// measured once via [TextPainter] rather than always marqueeing, so short
/// text just sits still like normal.
class MarqueeTitle extends StatelessWidget {
final String text;
final TextStyle? style;
const MarqueeTitle({super.key, required this.text, required this.style});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
if (painter.width <= constraints.maxWidth) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
);
}
return SizedBox(
height: painter.height,
child: Marquee(
text: text,
style: style,
blankSpace: 48,
velocity: 30,
startPadding: 0,
pauseAfterRound: const Duration(seconds: 1),
fadingEdgeStartFraction: 0.1,
fadingEdgeEndFraction: 0.15,
),
);
},
);
}
}
+14 -5
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../providers/server_provider.dart'; import '../providers/pick_controller.dart';
import '../providers/settings_controller.dart';
import 'app_tab_view_builder.dart'; import 'app_tab_view_builder.dart';
/// Opens a tab that's currently hidden from the bottom nav bar as its own /// Opens a tab that's currently hidden from the bottom nav bar as its own
@@ -13,16 +14,24 @@ class MoreTabsButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final pick = context.watch<PickController>();
final settings = context.watch<SettingsController>();
// While picking (another app's GET_CONTENT request), MainShellView
// already restricts the bottom nav to Files/Photos - this button would
// otherwise be the one remaining way to reach a tab that isn't a valid
// pick source (Trash, Shares, ...), so it hides entirely rather than
// just disabling.
if (pick.isPicking) return const SizedBox.shrink();
return PopupMenuButton<AppTab>( return PopupMenuButton<AppTab>(
icon: const Icon(Icons.apps_rounded), icon: const Icon(Icons.apps_rounded),
tooltip: 'More tabs', tooltip: 'More tabs',
enabled: provider.hiddenTabs.isNotEmpty, enabled: settings.hiddenTabs.isNotEmpty,
onSelected: (tab) => _openHiddenTab(context, tab), onSelected: (tab) => _openHiddenTab(context, tab),
itemBuilder: (context) => [ itemBuilder: (context) => [
for (final tab in provider.tabOrder.where( for (final tab in settings.tabOrder.where(
(t) => provider.hiddenTabs.contains(t), (t) => settings.hiddenTabs.contains(t),
)) ))
PopupMenuItem( PopupMenuItem(
value: tab, value: tab,
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import '../models/move_copy_result.dart';
import 'gradual_bottom_sheet.dart';
/// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts`
/// non-empty (an item with the same name already exists at the
/// destination) - summarizes every conflict at once rather than prompting
/// per item as they're hit, and lets the user resolve them all the same
/// way (overwrite/keep both) or open a per-item breakdown. Returns the
/// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null
/// if dismissed without choosing.
class MoveCopyConflictSheet extends StatefulWidget {
final List<MoveCopyConflict> conflicts;
final ScrollController? scrollController;
const MoveCopyConflictSheet({
super.key,
required this.conflicts,
this.scrollController,
});
static Future<Map<String, ConflictChoice>?> show(
BuildContext context,
List<MoveCopyConflict> conflicts,
) {
return showGradualBottomSheet<Map<String, ConflictChoice>>(
context,
builder: (context, scrollController) => MoveCopyConflictSheet(
conflicts: conflicts,
scrollController: scrollController,
),
);
}
@override
State<MoveCopyConflictSheet> createState() => _MoveCopyConflictSheetState();
}
class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
bool _decidingPerItem = false;
late final Map<String, ConflictChoice> _perItemChoices = {
for (final conflict in widget.conflicts)
conflict.item.id: ConflictChoice.keepBoth,
};
void _resolveAll(ConflictChoice choice) {
Navigator.pop(context, {
for (final conflict in widget.conflicts) conflict.item.id: choice,
});
}
void _confirmPerItem() {
Navigator.pop(context, _perItemChoices);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final count = widget.conflicts.length;
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
children: [
Text(
count == 1
? '1 item already exists at the destination'
: '$count items already exist at the destination',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Choose how to handle the conflicts.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
if (!_decidingPerItem) ...[
FilledButton(
onPressed: () => _resolveAll(ConflictChoice.overwrite),
child: const Text('Overwrite all'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => _resolveAll(ConflictChoice.keepBoth),
child: const Text('Keep both (rename)'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => setState(() => _decidingPerItem = true),
child: const Text('Decide per item'),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
],
for (final conflict in widget.conflicts)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
conflict.item.isFolder
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
conflict.item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
child: SegmentedButton<ConflictChoice>(
segments: const [
ButtonSegment(
value: ConflictChoice.overwrite,
label: Text('Overwrite'),
),
ButtonSegment(
value: ConflictChoice.keepBoth,
label: Text('Keep both'),
),
ButtonSegment(
value: ConflictChoice.skip,
label: Text('Skip'),
),
],
selected: {_perItemChoices[conflict.item.id]!},
showSelectedIcon: false,
onSelectionChanged: (selection) => setState(() {
_perItemChoices[conflict.item.id] = selection.first;
}),
),
),
],
],
),
),
if (_decidingPerItem) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _confirmPerItem,
child: const Text('Confirm'),
),
),
],
],
);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/server_provider.dart'; import '../providers/session_controller.dart';
import '../views/account_view.dart'; import '../views/account_view.dart';
/// Top-bar avatar button that opens the account screen. Replaces the old /// Top-bar avatar button that opens the account screen. Replaces the old
@@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget {
final velocity = details.primaryVelocity ?? 0; final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < 250) return; if (velocity.abs() < 250) return;
final provider = context.read<ServerProvider>(); final provider = context.read<SessionController>();
final target = velocity < 0 final target = velocity < 0
? await provider.cycleToNextAccount() ? await provider.cycleToNextAccount()
: await provider.cycleToPreviousAccount(); : await provider.cycleToPreviousAccount();
@@ -37,7 +37,7 @@ class ProfileAvatarButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final provider = context.watch<SessionController>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final initial = provider.username.isNotEmpty final initial = provider.username.isNotEmpty
? provider.username[0].toUpperCase() ? provider.username[0].toUpperCase()
+1 -1
View File
@@ -1,6 +1,6 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../providers/server_provider.dart'; import '../providers/settings_controller.dart';
/// Draws a video seek bar in one of the four [MediaProgressBarStyle] /// Draws a video seek bar in one of the four [MediaProgressBarStyle]
/// presets. Shared by the actual in-player seek bar and the small previews /// presets. Shared by the actual in-player seek bar and the small previews
+128 -29
View File
@@ -1,10 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart'; import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart'; import '../models/nextcloud_sharee.dart';
import '../providers/server_provider.dart'; import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
import 'details/details_sheet.dart' show DetailsHeader;
import 'gradual_bottom_sheet.dart'; import 'gradual_bottom_sheet.dart';
IconData _shareTypeIcon(ShareType type) { IconData _shareTypeIcon(ShareType type) {
@@ -66,6 +72,8 @@ class _ShareSheetState extends State<ShareSheet> {
List<NextcloudShare> _inherited = []; List<NextcloudShare> _inherited = [];
List<NextcloudSharee> _searchResults = []; List<NextcloudSharee> _searchResults = [];
bool _isSearching = false; bool _isSearching = false;
bool _isAddingEmail = false;
bool _isSharingFile = false;
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
@@ -77,10 +85,10 @@ class _ShareSheetState extends State<ShareSheet> {
} }
Future<void> _load() async { Future<void> _load() async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final results = await Future.wait([ final results = await Future.wait([
provider.fetchItemShares(widget.item), ops.fetchItemShares(widget.item),
provider.fetchInheritedShares(widget.item), ops.fetchInheritedShares(widget.item),
]); ]);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -97,8 +105,8 @@ class _ShareSheetState extends State<ShareSheet> {
return; return;
} }
setState(() => _isSearching = true); setState(() => _isSearching = true);
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final results = await provider.searchSharees(trimmed); final results = await ops.searchSharees(trimmed);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_searchResults = results; _searchResults = results;
@@ -107,9 +115,9 @@ class _ShareSheetState extends State<ShareSheet> {
} }
Future<void> _addSharee(NextcloudSharee sharee) async { Future<void> _addSharee(NextcloudSharee sharee) async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare( final share = await ops.createShare(
path: widget.item.path, path: widget.item.path,
shareType: sharee.shareTypeValue, shareType: sharee.shareTypeValue,
shareWith: sharee.shareWith, shareWith: sharee.shareWith,
@@ -140,15 +148,17 @@ class _ShareSheetState extends State<ShareSheet> {
Future<void> _addEmailShare() async { Future<void> _addEmailShare() async {
final email = _emailController.text.trim(); final email = _emailController.text.trim();
if (email.isEmpty) return; if (email.isEmpty) return;
final provider = context.read<ServerProvider>(); setState(() => _isAddingEmail = true);
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare( final share = await ops.createShare(
path: widget.item.path, path: widget.item.path,
shareType: 4, shareType: 4,
shareWith: email, shareWith: email,
); );
if (!mounted) return; if (!mounted) return;
if (share == null) { if (share == null) {
setState(() => _isAddingEmail = false);
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar( const SnackBar(
content: Text('Could not create email share'), content: Text('Could not create email share'),
@@ -160,6 +170,7 @@ class _ShareSheetState extends State<ShareSheet> {
setState(() { setState(() {
_shares = [..._shares, share]; _shares = [..._shares, share];
_emailController.clear(); _emailController.clear();
_isAddingEmail = false;
}); });
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
@@ -170,9 +181,9 @@ class _ShareSheetState extends State<ShareSheet> {
} }
Future<void> _createPublicLink() async { Future<void> _createPublicLink() async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare( final share = await ops.createShare(
path: widget.item.path, path: widget.item.path,
shareType: 3, shareType: 3,
); );
@@ -190,9 +201,9 @@ class _ShareSheetState extends State<ShareSheet> {
} }
Future<void> _removeShare(NextcloudShare share) async { Future<void> _removeShare(NextcloudShare share) async {
final provider = context.read<ServerProvider>(); final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share); final success = await ops.deleteShare(share);
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
setState(() => _shares = _shares.where((s) => s.id != share.id).toList()); setState(() => _shares = _shares.where((s) => s.id != share.id).toList());
@@ -216,13 +227,60 @@ class _ShareSheetState extends State<ShareSheet> {
); );
} }
/// Downloads the item to a scratch file and hands it to Android's native
/// "Share to..." sheet (`share_plus`) - a completely different action
/// from the rest of this sheet (which shares *within* Nextcloud, via
/// users/links) and the only one here that reads the file's actual
/// bytes.
Future<void> _shareFileDirectly() async {
final session = context.read<SessionController>();
final sync = context.read<SyncStatusController>();
if (session.service == null) return;
setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context);
try {
// Already mirrored locally by device sync? Share that copy straight
// away instead of a fresh WebDAV fetch - see
// SyncStatusController.localSyncedFilePath.
final tempPath =
await sync.localSyncedFilePath(widget.item) ??
await () async {
final tempDir = await getTemporaryDirectory();
final path = p.join(tempDir.path, widget.item.name);
await session.service!.downloadToFile(widget.item.path, path);
return path;
}();
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(tempPath)],
fileNameOverrides: [widget.item.name],
),
);
} catch (_) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not share the file'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSharingFile = false);
}
}
Widget _sectionHeader(String label) { Widget _sectionHeader(String label) {
final colorScheme = Theme.of(context).colorScheme; final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Row( return Row(
children: [ children: [
Text( Text(
label, label,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Icon( Icon(
@@ -279,7 +337,7 @@ class _ShareSheetState extends State<ShareSheet> {
} }
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final session = context.watch<SessionController>();
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -297,31 +355,56 @@ class _ShareSheetState extends State<ShareSheet> {
final emailShares = _shares final emailShares = _shares
.where((s) => s.shareType == ShareType.email) .where((s) => s.shareType == ShareType.email)
.toList(); .toList();
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id); final internalLink = internalLinkFor(session.serverUrl, widget.item.id);
return ListView( return ListView(
controller: widget.scrollController, controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
children: [ children: [
Text( // Same icon/name/meta header as DetailsSheet, so this and its
widget.item.name, // sibling per-item bottom sheet open on identical chrome - just a
maxLines: 1, // bare title `Text` here read as under-designed by comparison.
overflow: TextOverflow.ellipsis, DetailsHeader(
style: theme.textTheme.titleMedium?.copyWith( item: widget.item,
fontWeight: FontWeight.w700, padding: const EdgeInsets.only(top: 12, bottom: 8),
),
), ),
const SizedBox(height: 16), if (!widget.item.isFolder) ...[
const SizedBox(height: 4),
ListTile(
contentPadding: EdgeInsets.zero,
leading: _isSharingFile
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.ios_share_rounded),
title: const Text('Share file directly'),
subtitle: const Text('Send the file itself via another app'),
onTap: _isSharingFile ? null : _shareFileDirectly,
),
const SizedBox(height: 12),
const Divider(),
],
const SizedBox(height: 12),
_sectionHeader('Internal shares'), _sectionHeader('Internal shares'),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type names or teams', hintText: 'Type names or teams',
prefixIcon: const Icon(Icons.search_rounded), prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _isSearching suffixIcon: _isSearching
? const Padding( ? const Padding(
padding: EdgeInsets.all(14), padding: EdgeInsets.all(12),
child: SizedBox( child: SizedBox(
width: 16, width: 16,
height: 16, height: 16,
@@ -378,9 +461,25 @@ class _ShareSheetState extends State<ShareSheet> {
TextField( TextField(
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: !_isAddingEmail,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type an email', hintText: 'Type an email',
prefixIcon: const Icon(Icons.email_outlined), prefixIcon: const Icon(Icons.email_rounded),
suffixIcon: _isAddingEmail
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerLow, fillColor: colorScheme.surfaceContainerLow,
+1 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../providers/server_provider.dart'; import '../providers/files_controller.dart';
String sortFieldLabel(FileSortField field) { String sortFieldLabel(FileSortField field) {
switch (field) { switch (field) {
+5 -5
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../providers/server_provider.dart'; import '../providers/settings_controller.dart';
/// Wraps [child] with the user's configured swipe-left/swipe-right actions /// Wraps [child] with the user's configured swipe-left/swipe-right actions
/// (favorite, delete, share, or none) — shared between the Files list view /// (favorite, delete, share, or none) — shared between the Files list view
@@ -10,7 +10,7 @@ import '../providers/server_provider.dart';
class SwipeableItem extends StatelessWidget { class SwipeableItem extends StatelessWidget {
final Key itemKey; final Key itemKey;
final String itemName; final String itemName;
final ServerProvider provider; final SettingsController settings;
final Future<void> Function() onFavorite; final Future<void> Function() onFavorite;
final Future<void> Function() onShare; final Future<void> Function() onShare;
final Future<void> Function() onDelete; final Future<void> Function() onDelete;
@@ -20,7 +20,7 @@ class SwipeableItem extends StatelessWidget {
super.key, super.key,
required this.itemKey, required this.itemKey,
required this.itemName, required this.itemName,
required this.provider, required this.settings,
required this.onFavorite, required this.onFavorite,
required this.onShare, required this.onShare,
required this.onDelete, required this.onDelete,
@@ -69,8 +69,8 @@ class SwipeableItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final rightAction = provider.swipeRightAction; final rightAction = settings.swipeRightAction;
final leftAction = provider.swipeLeftAction; final leftAction = settings.swipeLeftAction;
final direction = _directionFor(leftAction, rightAction); final direction = _directionFor(leftAction, rightAction);
if (direction == DismissDirection.none) return child; if (direction == DismissDirection.none) return child;
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../models/sync_status.dart';
/// A small corner badge over a thumbnail showing device-sync status - a
/// `cloud_done` badge once a file's mirrored locally, `sync` while it's
/// actively being transferred. Nothing is drawn for `none`/`conflict`
/// (conflicts surface in the sync header, not as per-tile noise).
class SyncStatusBadge extends StatelessWidget {
final SyncItemStatus status;
const SyncStatusBadge({super.key, required this.status});
@override
Widget build(BuildContext context) {
if (status != SyncItemStatus.syncing && status != SyncItemStatus.synced) {
return const SizedBox.shrink();
}
final colorScheme = Theme.of(context).colorScheme;
final icon = status == SyncItemStatus.syncing
? Icons.sync_rounded
: Icons.cloud_done_rounded;
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 1.5),
),
child: Icon(icon, size: 13, color: colorScheme.primary),
);
}
}
+195 -45
View File
@@ -1,6 +1,46 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart'; import '../models/sync_status.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
/// Icon/label for the persistent header chip/panel - reflects device-sync
/// status (see `SyncStatusController.syncHeaderStatus`), not the WebDAV
/// directory-listing refresh the pull gesture itself triggers (that has
/// its own, separate floating spinner bubble - see
/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`).
(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status) {
return switch (status) {
SyncHeaderStatus.off => (Icons.cloud_off_rounded, 'Sync off'),
SyncHeaderStatus.syncing => (Icons.cloud_sync_rounded, 'Syncing…'),
SyncHeaderStatus.done => (Icons.cloud_done_rounded, 'Synced'),
SyncHeaderStatus.alert => (Symbols.cloud_alert_rounded, 'Sync issue'),
};
}
/// The expanded sync panel's headline - actual folder/item counts instead
/// of just repeating the compact chip's generic "Synced" label, which reads
/// as true even when nothing has actually finished syncing yet (e.g. right
/// after adding a folder, before the first pass completes).
String _syncSummary(SyncStatusController sync) {
if (sync.syncConflicts.isNotEmpty) return 'Sync issue';
if (sync.isSyncingNow) return 'Syncing…';
final items = sync.syncedItemCount;
if (sync.syncEverything) {
return items > 0 ? '$items item${items == 1 ? '' : 's'} synced' : 'Sync off';
}
final folders = sync.syncedFolderCount;
if (folders == 0) return 'Sync off';
final folderWord = folders == 1 ? 'folder' : 'folders';
if (items == 0) {
return '$folders $folderWord selected — nothing synced yet';
}
final itemWord = items == 1 ? 'item' : 'items';
return '$folders $folderWord & $items $itemWord synced';
}
String formatBytes(int bytes) { String formatBytes(int bytes) {
if (bytes <= 0) return '0 B'; if (bytes <= 0) return '0 B';
@@ -26,7 +66,6 @@ String formatQuota(NextcloudUserQuota quota) {
/// holds the tab's own content slivers. /// holds the tab's own content slivers.
class SyncedHeaderScaffold extends StatefulWidget { class SyncedHeaderScaffold extends StatefulWidget {
final ScrollController scrollController; final ScrollController scrollController;
final ServerProvider provider;
/// Trailing icons in the app bar (e.g. an upload button, the avatar). /// Trailing icons in the app bar (e.g. an upload button, the avatar).
final List<Widget> actions; final List<Widget> actions;
@@ -37,17 +76,26 @@ class SyncedHeaderScaffold extends StatefulWidget {
final List<Widget> contentSlivers; final List<Widget> contentSlivers;
/// What a deliberate pull-to-sync should trigger. Defaults to /// What a deliberate pull-to-sync should trigger. Defaults to
/// [ServerProvider.refreshData] (the current folder's listing); pass e.g. /// [FilesController.refreshData] (the current folder's listing); pass
/// `provider.fetchTrash` for a tab backed by different data. /// e.g. `trashController.fetchAll` for a tab backed by different data.
final Future<void> Function()? onRefresh; final Future<void> Function()? onRefresh;
/// When non-null (a tab is mid-selection), this takes over the pinned top
/// bar entirely - replacing the sync-status chip, [actions], and the
/// pull-to-reveal quota panel - instead of appearing as a second app bar
/// further down in [contentSlivers]. Selection is a modal-ish state (you
/// came here to act on specific items), so it reads better as the one
/// thing at the very top than as a strip sandwiched under the normal
/// chrome.
final Widget? selectionBar;
const SyncedHeaderScaffold({ const SyncedHeaderScaffold({
super.key, super.key,
required this.scrollController, required this.scrollController,
required this.provider,
required this.actions, required this.actions,
required this.contentSlivers, required this.contentSlivers,
this.onRefresh, this.onRefresh,
this.selectionBar,
}); });
@override @override
@@ -92,7 +140,8 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
} }
void _startRefresh() { void _startRefresh() {
final refresh = widget.onRefresh ?? widget.provider.refreshData; final refresh =
widget.onRefresh ?? context.read<FilesController>().refreshData;
setState(() => _isRefreshing = true); setState(() => _isRefreshing = true);
refresh().whenComplete(() { refresh().whenComplete(() {
if (mounted) setState(() => _isRefreshing = false); if (mounted) setState(() => _isRefreshing = false);
@@ -137,7 +186,7 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final provider = widget.provider; final syncStatus = context.watch<SyncStatusController>();
final Widget leadingWidget; final Widget leadingWidget;
if (_headerLocked) { if (_headerLocked) {
@@ -149,7 +198,7 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
} else if (_isPulling) { } else if (_isPulling) {
leadingWidget = const SizedBox.shrink(); leadingWidget = const SizedBox.shrink();
} else { } else {
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen); leadingWidget = _SyncStatusChip(onTap: _lockOpen);
} }
final topInset = MediaQuery.of(context).padding.top; final topInset = MediaQuery.of(context).padding.top;
@@ -175,22 +224,33 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
pinned: true, pinned: true,
stretch: true, stretch: widget.selectionBar == null,
expandedHeight: _headerLocked ? 190 : kToolbarHeight, expandedHeight: widget.selectionBar != null
? kToolbarHeight
: (_headerLocked
? 190.0 + syncStatus.syncConflicts.length * 52.0
: kToolbarHeight),
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer, backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer, surfaceTintColor: colorScheme.surfaceContainer,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
leadingWidth: _headerLocked ? 56 : 160, leadingWidth: widget.selectionBar != null
leading: leadingWidget, ? 0
actions: widget.actions, : (_headerLocked ? 56 : 160),
flexibleSpace: FlexibleSpaceBar( leading: widget.selectionBar != null ? null : leadingWidget,
background: _SyncedStretchPanel( titleSpacing: widget.selectionBar != null ? 0 : null,
provider: provider, title: widget.selectionBar,
forceVisible: _headerLocked, actions: widget.selectionBar != null
), ? const []
), : widget.actions,
flexibleSpace: widget.selectionBar != null
? null
: FlexibleSpaceBar(
background: _SyncedStretchPanel(
forceVisible: _headerLocked,
),
),
), ),
DecoratedSliver( DecoratedSliver(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -249,16 +309,19 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
/// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling /// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling
/// the header down to expand that same "Synced" view. /// the header down to expand that same "Synced" view.
class _SyncStatusChip extends StatelessWidget { class _SyncStatusChip extends StatelessWidget {
final ServerProvider provider;
final VoidCallback onTap; final VoidCallback onTap;
const _SyncStatusChip({required this.provider, required this.onTap}); const _SyncStatusChip({required this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final quota = provider.quota; final status = context.watch<SyncStatusController>().syncHeaderStatus;
final (icon, label) = _syncHeaderDisplay(status);
final iconColor = status == SyncHeaderStatus.alert
? colorScheme.error
: colorScheme.primary;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 12), padding: const EdgeInsets.only(left: 12),
@@ -273,16 +336,10 @@ class _SyncStatusChip extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(icon, color: iconColor, size: 18),
provider.isLoading
? Icons.cloud_sync_rounded
: Icons.cloud_done_outlined,
color: colorScheme.primary,
size: 18,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
quota != null ? formatQuota(quota) : 'Sync', label,
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -301,33 +358,32 @@ class _SyncStatusChip extends StatelessWidget {
/// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays /// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays
/// locked open. /// locked open.
class _SyncedStretchPanel extends StatelessWidget { class _SyncedStretchPanel extends StatelessWidget {
final ServerProvider provider;
/// Keeps the panel fully shown even without live overscroll — used once /// Keeps the panel fully shown even without live overscroll — used once
/// the header has "locked" open after a deliberate pull-down. /// the header has "locked" open after a deliberate pull-down.
final bool forceVisible; final bool forceVisible;
const _SyncedStretchPanel({ const _SyncedStretchPanel({this.forceVisible = false});
required this.provider,
this.forceVisible = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final topInset = MediaQuery.of(context).padding.top; final topInset = MediaQuery.of(context).padding.top;
final quota = context.watch<FilesController>().quota;
final serverUrl = context.watch<SessionController>().serverUrl;
final syncStatus = context.watch<SyncStatusController>();
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final settings = context final spacerSettings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>(); .dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final maxExtent = settings?.maxExtent ?? constraints.maxHeight; final maxExtent = spacerSettings?.maxExtent ?? constraints.maxHeight;
final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0); final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0);
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably // Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
// before the 100px lock threshold. // before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0); final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final quota = provider.quota; final statusLabel = _syncSummary(syncStatus);
final conflicts = syncStatus.syncConflicts;
return Stack( return Stack(
children: [ children: [
@@ -345,18 +401,15 @@ class _SyncedStretchPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
provider.isLoading ? 'Syncing…' : 'Synced', statusLabel,
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
if (provider.serverUrl.isNotEmpty) ...[ if (serverUrl.isNotEmpty) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
provider.serverUrl.replaceFirst( serverUrl.replaceFirst(RegExp(r'^https?://'), ''),
RegExp(r'^https?://'),
'',
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
@@ -394,6 +447,14 @@ class _SyncedStretchPanel extends StatelessWidget {
], ],
), ),
), ),
if (conflicts.isNotEmpty) ...[
const SizedBox(height: 10),
for (final conflict in conflicts)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _SyncConflictRow(conflict: conflict),
),
],
], ],
), ),
), ),
@@ -404,3 +465,92 @@ class _SyncedStretchPanel extends StatelessWidget {
); );
} }
} }
/// One row in the locked-open panel's conflicts section - a file that
/// changed both locally and on the server since the last sync, with the
/// same two resolutions the sync-conflict notification offers (see
/// `SyncWorker.notifyConflicts`/`ConflictResolveWorker` on the native
/// side), just triggered in-app instead.
class _SyncConflictRow extends StatefulWidget {
final SyncConflictInfo conflict;
const _SyncConflictRow({required this.conflict});
@override
State<_SyncConflictRow> createState() => _SyncConflictRowState();
}
class _SyncConflictRowState extends State<_SyncConflictRow> {
bool _resolving = false;
Future<void> _resolve(bool useLocal) async {
setState(() => _resolving = true);
try {
await context.read<SyncStatusController>().resolveSyncConflict(
widget.conflict,
useLocal: useLocal,
);
} catch (_) {
if (mounted) setState(() => _resolving = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Symbols.cloud_alert_rounded, color: colorScheme.error, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.conflict.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (_resolving)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else ...[
TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _resolve(true),
child: const Text('Keep local'),
),
TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _resolve(false),
child: const Text('Use server'),
),
],
],
),
);
}
}
+48
View File
@@ -712,6 +712,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8
url: "https://pub.dev"
source: hosted
version: "9.6.1"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
url: "https://pub.dev"
source: hosted
version: "0.1.4+1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531
url: "https://pub.dev"
source: hosted
version: "4.4.1"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
url: "https://pub.dev"
source: hosted
version: "0.2.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -55,6 +55,7 @@ dependencies:
marquee: ^2.3.0 marquee: ^2.3.0
local_auth: ^2.3.0 local_auth: ^2.3.0
webview_flutter: ^4.14.1 webview_flutter: ^4.14.1
permission_handler: ^11.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+54 -9
View File
@@ -4,7 +4,17 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:noo/main.dart'; import 'package:noo/main.dart';
import 'package:noo/providers/server_provider.dart'; import 'package:noo/providers/favorites_controller.dart';
import 'package:noo/providers/files_controller.dart';
import 'package:noo/providers/item_operations.dart';
import 'package:noo/providers/photos_controller.dart';
import 'package:noo/providers/pick_controller.dart';
import 'package:noo/providers/recent_controller.dart';
import 'package:noo/providers/session_controller.dart';
import 'package:noo/providers/settings_controller.dart';
import 'package:noo/providers/shares_controller.dart';
import 'package:noo/providers/sync_status_controller.dart';
import 'package:noo/providers/trash_controller.dart';
void main() { void main() {
const secureStorageChannel = MethodChannel( const secureStorageChannel = MethodChannel(
@@ -21,20 +31,55 @@ void main() {
}); });
}); });
test('ServerProvider initializes unauthenticated', () { test('SessionController initializes unauthenticated', () {
final provider = ServerProvider(); final session = SessionController();
expect(provider.isLoggedIn, false); expect(session.isLoggedIn, false);
expect(provider.items.isEmpty, true);
}); });
testWidgets('App renders LoginView when not logged in', ( testWidgets('App renders LoginView when not logged in', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
final provider = ServerProvider();
await tester.pumpWidget( await tester.pumpWidget(
ChangeNotifierProvider.value( MultiProvider(
value: provider, // Mirrors main.dart's own provider order/wiring.
providers: [
ChangeNotifierProvider(create: (_) => SessionController()),
ChangeNotifierProvider(create: (_) => SettingsController()),
ChangeNotifierProvider(
create: (context) => SyncStatusController(context.read()),
),
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
create: (context) =>
PhotosController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) =>
FavoritesController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) => TrashController(context.read()),
),
ChangeNotifierProvider(
create: (context) => SharesController(context.read()),
),
ChangeNotifierProvider(
create: (context) => RecentController(context.read()),
),
ChangeNotifierProvider(
create: (context) => PickController(context.read()),
),
Provider(
create: (context) => ItemOperations(
session: context.read(),
files: context.read(),
photos: context.read(),
favorites: context.read(),
),
),
],
child: const NextcloudApp(), child: const NextcloudApp(),
), ),
); );
@@ -11,6 +11,7 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h> #include <local_auth_windows/local_auth_plugin.h>
#include <pdfx/pdfx_plugin.h> #include <pdfx/pdfx_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
@@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("LocalAuthPlugin")); registry->GetRegistrarForPlugin("LocalAuthPlugin"));
PdfxPluginRegisterWithRegistrar( PdfxPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PdfxPlugin")); registry->GetRegistrarForPlugin("PdfxPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar( SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
+1
View File
@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows flutter_secure_storage_windows
local_auth_windows local_auth_windows
pdfx pdfx
permission_handler_windows
share_plus share_plus
url_launcher_windows url_launcher_windows
) )