Compare commits
@@ -57,15 +57,16 @@ new provider instance):
|
||||
saved-accounts list, which one is active, `sessionGeneration` — see
|
||||
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
||||
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
|
||||
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
|
||||
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
||||
controllers — constructed after `SessionController` and unable to hold a
|
||||
forward reference to it — can react to login/logout/account-switch
|
||||
without a circular dependency.
|
||||
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
|
||||
global UI prefs independent of login state: theme mode/seed color/dynamic
|
||||
color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab
|
||||
color, bottom-bar opacity/blur, avatar and selection action-bar positions,
|
||||
tap-tab-to-scroll-top, seek bar style, tab
|
||||
order/visibility/default (`requestedTab`/`requestTab`/
|
||||
`consumeRequestedTab`), swipe actions.
|
||||
- [`FilesController`](../../lib/providers/files_controller.dart) — the
|
||||
@@ -282,11 +283,13 @@ the sidebar):
|
||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||
for *every* tab (previously only Files had shell-level top chrome, with
|
||||
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
|
||||
large title, an inline search field, a `plus` action on Files only (no
|
||||
other tab has a create/upload flow), and the account avatar; Android
|
||||
large title with Upload and the top account avatar on the same row,
|
||||
and an inline search field below; Android
|
||||
gets a compact title row with `search`/avatar actions, relying on an
|
||||
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
||||
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
|
||||
extended `NooFab` instead of a top-bar upload button. Both use the same
|
||||
size preference: Auto expands on Files/Photos and collapses elsewhere,
|
||||
Mini always collapses, and Expanded always shows Upload. Rather than one
|
||||
shared instance in `Scaffold.appBar`,
|
||||
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
||||
tab) and each tab plants its own as the first sliver in its own
|
||||
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
||||
@@ -312,8 +315,13 @@ the sidebar):
|
||||
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||
instead, a dropdown holding the same hidden-tabs + Settings content -
|
||||
see `styling.md`'s Gotchas for the wiring.
|
||||
instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
|
||||
card from the top or bottom avatar; `AvatarNavigationBody` moves only
|
||||
content for the top popup. The bottom popup overlays content. Floating
|
||||
mode moves the avatar into the popup and hands its satellite slot to
|
||||
Search; attached mode keeps the avatar and Search in the full-width bar
|
||||
and expands the popup upward from it. Position is a global setting.
|
||||
See `styling.md` for motion and dismissal behavior.
|
||||
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
||||
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
||||
(tab title, search, an "Upload" action on Files/Photos) over the same
|
||||
|
||||
@@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too).
|
||||
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||
Flutter's own default to already be high enough.
|
||||
|
||||
**The three locks are independent** (Settings -> Security;
|
||||
`SessionController`): `loginLockEnabled` (unlock to open the app - the only one
|
||||
`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to
|
||||
switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden
|
||||
files). Each gate works with the others off - `passGate(gate, reason)`
|
||||
prompts whenever its own `gate` is on, whether or not login lock is. Turning
|
||||
any of them on **or off** needs a successful `AppLockService.authenticate`
|
||||
(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set;
|
||||
off needs it so someone with a momentarily unlocked phone can't remove it),
|
||||
and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle
|
||||
dependency is gone. Tests replace the prompt through
|
||||
`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`.
|
||||
|
||||
`isRestoringSession` still gates the splash screen until the above resolves
|
||||
— see `standards.md` for why widget tests must mock both storage channels
|
||||
rather than relying on this async path throwing naturally.
|
||||
@@ -811,3 +824,243 @@ rather than relying on this async path throwing naturally.
|
||||
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||
|
||||
## Native services on iOS (in progress)
|
||||
|
||||
The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
|
||||
`upload_service`, `download_service`, `sync_service`, plus their status
|
||||
`EventChannel`s) were written for Android in Kotlin. iOS implements
|
||||
`upload_service` (+ its status stream), `download_service` and
|
||||
`share_intent` and `sync_service` so far (`ios/Runner/Native/` +
|
||||
`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so
|
||||
calls throw `MissingPluginException`. `lib/services/native_channel.dart`
|
||||
makes that safe until each one is built in Swift:
|
||||
|
||||
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
|
||||
checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and
|
||||
scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`,
|
||||
`finishPick`/`cancelPick`).
|
||||
- `invokeOrExplain` - throws `NativeServiceUnavailable("<Feature>")`, whose
|
||||
message callers already show in a snackbar ("Uploading isn't available on
|
||||
this platform yet."). For things the user just asked for: `startUpload`,
|
||||
`startDownload`, `syncNow`, `resolveConflict`.
|
||||
- `quietEvents` - wraps an `EventChannel` so the missing-implementation error
|
||||
is dropped instead of surfacing as an unhandled stream error.
|
||||
|
||||
When a channel gets a real iOS implementation nothing changes on the Dart
|
||||
side - the helpers only act on `MissingPluginException`.
|
||||
|
||||
`SyncService.baseDirectory()` is where the `sync/<accountId>/...` mirror
|
||||
lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt`
|
||||
writes), the app support directory elsewhere, because that call throws on
|
||||
iOS. `OfflineController` and `localSyncedFilePath` both go through it.
|
||||
### iOS upload/download (`ios/Runner/Native/`)
|
||||
|
||||
- `NativeServices.swift` registers the channels from
|
||||
`AppDelegate.didInitializeImplicitFlutterEngine` (via
|
||||
`engineBridge.applicationRegistrar.messenger()`) and forwards
|
||||
`startUpload`/`startDownload` to `TransferManager`; a batch's completion
|
||||
goes back over `upload_service/status` so `FilesController` refreshes the
|
||||
folder like on Android.
|
||||
- `TransferManager.swift` runs everything on one *background* `URLSession`
|
||||
(`dev.ayushya.noo.transfers`, `sessionSendsLaunchEvents`), so transfers
|
||||
finish after the app is suspended/closed; `AppDelegate` hands the system's
|
||||
`handleEventsForBackgroundURLSession` completion handler to it and calls
|
||||
`reconnect()` at launch. Each task carries its info in `taskDescription`
|
||||
and its batch's running totals live in `UserDefaults`, so the one summary
|
||||
local notification ("Uploaded 3 files") still fires after a relaunch.
|
||||
- Uploads: one `PUT` per file to `<server>/remote.php/dav/files/<user>/...`
|
||||
from a staged copy in Caches (no chunking, so very large files are bound by
|
||||
the server's single-request limit; an existing file with the same name is
|
||||
overwritten). Sends `X-OC-Mtime`. Downloads: `GET` into
|
||||
`Documents/Downloads`, with `UIFileSharingEnabled` +
|
||||
`LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under
|
||||
"On My iPhone > Noo"; a name clash becomes `a (1).txt`.
|
||||
- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3
|
||||
files..." when a batch starts, replaced in place (same request id =
|
||||
`transfer.<batch>`) by "Uploaded 3 files" when it ends. iOS can't show live
|
||||
byte progress from a background session the way Android's foreground
|
||||
service does (a Live Activity would, but needs a widget extension), and
|
||||
there's no cancel yet. The Dart snackbar text still says "see the
|
||||
notification for progress".
|
||||
- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and
|
||||
covered by `ios/RunnerTests` (`xcodebuild test -workspace
|
||||
ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS
|
||||
Simulator,id=<udid>'`).
|
||||
|
||||
### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`)
|
||||
|
||||
"Share to Noo" from any app's share sheet. A Share Extension can't open its
|
||||
host app (`extensionContext.open` is Today-widget-only), so - like
|
||||
Reminders/Notes - the destination is picked *inside* the sheet:
|
||||
|
||||
1. `ShareViewController` copies each shared file (`NSItemProvider.
|
||||
loadFileRepresentation`, public.item) into the App Group container
|
||||
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
|
||||
`SharePickerView` (`ShareModel` holds its state).
|
||||
2. It reads the accounts from the shared Keychain (`SharedAccountStore` ->
|
||||
`SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND`
|
||||
Depth 1, parsed by `DavFolderParser`). With **several accounts an account
|
||||
list comes first** (the app's active one is marked "Active"; "Change
|
||||
account" returns to it); with one it goes straight to its folders. The
|
||||
Dart side keeps the keychain data current: `ShareAccountSync`
|
||||
(`lib/services/share_account_service.dart`, started from the shell's
|
||||
`initState`) republishes - through the native `share_account` channel's
|
||||
`setAccounts` - when an account becomes ready or when its change
|
||||
signature moves: the account list/active account, the three lock
|
||||
settings, or the active account's hidden-files filter / storage scope;
|
||||
sign-out calls
|
||||
`clearAccount`. It publishes every saved account that has a stored
|
||||
password and isn't signed out, each with its **own Files hidden-files
|
||||
filter** (`FilesController.savedHiddenFilter`).
|
||||
- **Toggles**: the folder list has two menu chips, **Hidden** (hide / only
|
||||
hidden / all) and **Storage** (cloud only / only external / all +
|
||||
external), each starting at the chosen account's own app setting
|
||||
(`hiddenFilter`, `storageScope` published per account) and changeable for
|
||||
this share only - nothing is written back. The list is fetched once per
|
||||
folder (`DavClient.listFolders` returns everything, hidden and external
|
||||
included, with `nc:mount-type` requested) and filtered in memory
|
||||
(`ShareModel.visibleFolders`), so flipping a toggle just returns to the
|
||||
top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when
|
||||
it *or any ancestor* starts with a dot. External storage: only a mount's
|
||||
*root* has `mount-type=external`, so `ShareModel` remembers the roots it
|
||||
has seen and treats anything under one as external too (the app's own
|
||||
filter doesn't, which empties its list inside a mount). "All" lists
|
||||
external folders in their own "External storage" group.
|
||||
- **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening
|
||||
the sheet with the account's filter already not `hide`, asks for
|
||||
Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the
|
||||
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
|
||||
like `local_auth` with `biometricOnly: false`); cancelling keeps them
|
||||
hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never
|
||||
asks.
|
||||
- **Account switching**: choosing any account other than the app's
|
||||
active one asks for the same unlock when "lock account switching" is on.
|
||||
**Every gated action prompts every time**, exactly like the app - no
|
||||
unlock is remembered for the sheet (an earlier version cached one, so
|
||||
later account picks / hidden toggles skipped Face ID). The only
|
||||
merging is within one action: picking another account whose own filter
|
||||
already shows hidden folders is a single prompt covering both
|
||||
(`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested).
|
||||
Declining the account unlock cancels the pick; declining only the
|
||||
hidden unlock shows the account with hidden folders hidden.
|
||||
- Opening the sheet itself is not gated - only these two actions are
|
||||
(as in the app, where login lock guards launch and these are separate
|
||||
locks).
|
||||
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
||||
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
||||
`sharedContainerIdentifier`) that outlives the extension, and posts
|
||||
"Uploading N files...". Results go to whichever process is alive:
|
||||
files that finish while the sheet is still open are counted by the
|
||||
extension's `ShareUploadDelegate`; the rest by the app, which recreates
|
||||
that session at launch (`TransferManager.reconnect`) and is relaunched by
|
||||
the system. Either way `UploadResults.handle` deletes the staged copy,
|
||||
counts the file in `TransferBatchStore` (batch totals live in the App
|
||||
Group's `UserDefaults` because the two are different processes) and
|
||||
posts the "Uploaded N files" summary on the last one; an upload the
|
||||
extension finished leaves a note (`noteFinishedUpload`) so the app
|
||||
refreshes that folder on its next activation. **Don't give the
|
||||
extension's session no delegate** - a fast upload then finishes unseen and
|
||||
the invalidated session is discarded before the app can ever hear of it
|
||||
(found the hard way: no notification, no count).
|
||||
4. Fallbacks: with no account (or the user taps "Choose a folder later in
|
||||
Noo") the files go into `SharedInbox/pending.json` instead and a local
|
||||
notification asks the user to open Noo; the app consumes that through
|
||||
`share_intent` (`getInitialShare` at cold start, `share_intent/new` on
|
||||
`UIApplication.didBecomeActiveNotification`) into the same Dart
|
||||
`ShareUploadView` flow as an Android share, and `TransferManager.
|
||||
startUpload` deletes the inbox copy once staged.
|
||||
|
||||
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
|
||||
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
|
||||
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
|
||||
two processes always agree on the team-prefixed id. Everything is stored as
|
||||
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
|
||||
|
||||
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
|
||||
`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that
|
||||
isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is
|
||||
`dev.ayushya.noo.ShareExtension` and its version/build come from the same
|
||||
`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a
|
||||
mismatch); it's embedded by an "Embed Foundation Extensions" phase placed
|
||||
*before* Flutter's script phases (after them Xcode reports a dependency
|
||||
cycle). The simulator honours the App Group and keychain group without a
|
||||
signing team; a real device needs a team that owns both ids.
|
||||
|
||||
### iOS device sync (`ios/Runner/Native/SyncEngine/`)
|
||||
|
||||
A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/
|
||||
`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service`
|
||||
channel (+ `/status` event stream) so the Dart side is unchanged apart from
|
||||
`cancelAccount`'s `forget` flag (below). Same rules: only paths the user
|
||||
synced are mirrored; brand-new local files are never uploaded (only edits to
|
||||
already-synced files are); a file edited on both sides is a conflict (neither
|
||||
copy is touched, "Keep local"/"Use server" resolves it); a local deletion is
|
||||
repaired by re-downloading; the mirror lives at
|
||||
`<Application Support>/sync/<accountId>/...` (what Dart's
|
||||
`SyncService.baseDirectory()` reads for the Offline tab, excluded from
|
||||
backup) with state in `<...>/sync-state/<accountId>/*.json`.
|
||||
|
||||
- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient`
|
||||
(PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one
|
||||
account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator`
|
||||
(runs, `BGTaskScheduler`, notifications), and the `registerSync` channel
|
||||
handlers in `NativeServices.swift`.
|
||||
- **Safeguards (each has a regression test in `SyncEngineTests`, which drives
|
||||
the real runner against an in-memory fake Nextcloud):** an unreachable
|
||||
server, a non-207, or a PROPFIND answer that is truncated, not a
|
||||
`multistatus`, empty, or has an href outside the account root / with dot
|
||||
components throws `SyncRemoteUnavailable` and the path is skipped - never
|
||||
diffed as empty or partial (which would delete local files); unreadable
|
||||
recorded state makes the run do nothing (`SyncStateUnreadable`) instead of
|
||||
treating every file as untracked and overwriting local edits; every
|
||||
server-supplied path is resolved through `LocalFS.resolve` (no escaping the
|
||||
mirror) and entries that would land on the same local file (case or Unicode
|
||||
spellings) are skipped, not downloaded over each other; downloads go to a
|
||||
temp file and replace atomically, never replacing a folder; empty-folder
|
||||
pruning uses `rmdir` and only on a successful empty read; a root's etag
|
||||
shortcut marker is only written when the path synced cleanly *and* the state
|
||||
was saved.
|
||||
- **Server deletions:** remove the local copy even if edited locally, matching
|
||||
Android.
|
||||
- **Deliberate differences from Android:** after our own upload the file's
|
||||
etag is re-read so it isn't downloaded back; state is flushed every 20 transfers; conflicts are kept
|
||||
per account on the status bus; downloads write to a temp file first. No
|
||||
ongoing progress notification (a run posts a summary, and conflict
|
||||
notifications with Keep local / Use server actions).
|
||||
- **Running:** every run - "Sync now", the in-app refresh, and background -
|
||||
goes through `SyncCoordinator.startRun`: one tracked run per account
|
||||
(`force` supersedes it, a quiet check leaves it alone), serialised by one
|
||||
cancellable `AsyncMutex`, tagged with a per-account *generation* so a run
|
||||
that was only queued never starts for an account that was cancelled or had a
|
||||
path removed meanwhile. `removeLocalSync` cancels the run and takes the same
|
||||
mutex, so a finishing run can't write stale state back over it.
|
||||
- **Background:** `BGAppRefreshTask` + `BGProcessingTask`
|
||||
(`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch +
|
||||
processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers
|
||||
registered before launch finishes). `intervalMinutes` (floored at 15, like
|
||||
Android) is only the *earliest* start - iOS picks when. The Files cache
|
||||
settings explain this on iOS; the 15-minute Android warning appears only
|
||||
on Android for intervals below 15 minutes. Wi-Fi-only is
|
||||
checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require
|
||||
it. An expiring task cancels its runs (they save state as they go) and is
|
||||
completed exactly once (`OnceGate`). **Not testable in the simulator**
|
||||
(needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier`
|
||||
debugger command).
|
||||
- **Simulator verification:** a temporary debug control ran the native
|
||||
background slot and confirmed files update for a non-active saved account.
|
||||
The control and its channel handler were removed after verification.
|
||||
`startBackgroundSlot` retains a regression test for periodic eligibility.
|
||||
OS wake-up delivery and background execution time limits remain unverified.
|
||||
- **Credentials:** `SyncConfigStore` keeps each account's server, auth header,
|
||||
paths, interval and notify flag as one Keychain item
|
||||
(`AfterFirstUnlock`, so a background run works with the phone locked).
|
||||
Conflict notification actions look the account up there - nothing secret is
|
||||
in a notification. `sync_service.cancel` takes `forget` (default true:
|
||||
signed out/removed -> credentials dropped; false: only background sync was
|
||||
turned off -> kept for manual runs and conflict actions). Dart sends
|
||||
`forget: false` only for "paths configured but no background interval".
|
||||
- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on
|
||||
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
|
||||
|
||||
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
|
||||
|
||||
@@ -29,6 +29,13 @@ widgets. Key points:
|
||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
|
||||
`surface` (cards, selection bar, rows) from the container roles so it
|
||||
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
|
||||
`surfaceContainer` in dark. The dynamic_color package seeds the container
|
||||
roles from the primary color rather than the OS palette, and in light mode
|
||||
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
|
||||
made cards blend into the page ("the selection bar has no background").
|
||||
- **Font**: Inter via `google_fonts`, applied through
|
||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||
@@ -79,7 +86,7 @@ Catalog:
|
||||
|
||||
| Folder | Components |
|
||||
|---|---|
|
||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
||||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||
@@ -116,7 +123,8 @@ Gotchas:
|
||||
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
||||
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
||||
track blends invisibly into the sheet instead of reading as a grouped
|
||||
control (Files'/Photos' sort and type-filter sheets do this).
|
||||
control (Files'/Photos' filter sheets and Settings controls on surface
|
||||
rows use the same `surface2` track).
|
||||
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
||||
on its scrollable child, or pull-to-refresh silently can't be triggered
|
||||
once the list is short enough to fit the viewport (empty, or one item) -
|
||||
@@ -135,6 +143,32 @@ blocks are noted where they matter:
|
||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||
Offline), and reused as-is by Favorites.
|
||||
- Selection action bars in Files, Photos, and Favorites have a global
|
||||
Settings → Action bar → Position choice: Top (default) or Bottom,
|
||||
persisted as `ui_selection_bar_position`. Top stays pinned in the content
|
||||
header; Bottom uses `SelectionBarOverlay` above navigation (or the content
|
||||
pane's bottom on desktop), with extra list clearance. The overlay uses
|
||||
Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation
|
||||
and safe areas are accounted for without counting their height twice.
|
||||
The shell hides Android's Upload FAB during selection via view callbacks.
|
||||
The mobile Action bar settings use one grouped card for Position and
|
||||
the reorder list, with no nested rounded corners or repeated page title.
|
||||
Their header backdrop is transparent, so content scrolls behind the inset
|
||||
`surface` card. A `line` border and `nooDialogShadow` match the floating
|
||||
bottom bar's solid treatment.
|
||||
- Hidden files and external storage are each a three-way segmented row
|
||||
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||
in both the Files and Photos filter sheets), not toggles:
|
||||
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
|
||||
cloud (default) / external / all. Files and Photos each persist their own
|
||||
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||
`StorageScope` stays shared. Photos uses the same segmented pill for
|
||||
Favorites (All / Favorites only). Leaving `hide` is still behind the
|
||||
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
|
||||
regular items first, then a collapsible "External storage" section
|
||||
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
||||
holding the external ones; Photos/Favorites just merge them into one list.
|
||||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||
trail Files uses in place of the shared
|
||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||
@@ -229,9 +263,27 @@ blocks are noted where they matter:
|
||||
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||||
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||||
don't confuse the two: this one is still non-blurred, just inset with a
|
||||
`line` border and `nooDialogShadow` (the app's one other shadow user - see
|
||||
`line` border and `nooDialogShadow` (also used by floating selection bars - see
|
||||
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||||
either). The host `Scaffold` needs `extendBody: true` while it's active
|
||||
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||
styles) swaps the bar's solid `surface` for a translucent one over a
|
||||
`BackdropFilter` blur and drops
|
||||
`nooDialogShadow` (it would show through the glass). Strength is user-set:
|
||||
`FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the
|
||||
old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity
|
||||
(0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown
|
||||
only while frosted glass is on; both persist
|
||||
(`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as
|
||||
`NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once
|
||||
the sliders are moved off all three. A frosted bar only
|
||||
reads as glass with content behind it, so the shell uses
|
||||
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
|
||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||
which also means every tab's own scrollable list has to reserve enough
|
||||
bottom padding to clear the bar - nothing does that automatically once the
|
||||
@@ -244,44 +296,50 @@ blocks are noted where they matter:
|
||||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||
anywhere that needs the *current* cap on regular tabs.
|
||||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
||||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
||||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
||||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
||||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
||||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
||||
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
||||
the drawer. The menu header has a chevron that expands an account section
|
||||
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
||||
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||
not a modal flow) - there's no existing anchored-popup primitive here
|
||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
||||
inset, not the top bar's full height on top of that) so it covers the
|
||||
top bar - including the tab title - rather than sitting below it, and
|
||||
its card carries two stacked `boxShadow`s rather than just
|
||||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
||||
to read as basically invisible on a small card over a dark theme's
|
||||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
||||
edge to be visible against an already-dark backdrop), so a second,
|
||||
tighter, more opaque contact shadow underneath it gives real elevation
|
||||
in both themes. The card's border can go missing wherever an opaque row
|
||||
sits against it too (every corner but the header's, which has no
|
||||
full-bleed fill of its own) if a `Container` combines `border` with its
|
||||
own `clipBehavior` - that paints the border as part of the *outer*
|
||||
decoration, then the clipped child on top right up to the same boundary,
|
||||
with no gap for the border's own stroke to show through. Fixed the same
|
||||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
||||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
||||
reduced by that same 1px) around the filled, clipped content instead, so
|
||||
it never paints over the border. Also added
|
||||
- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
|
||||
navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
|
||||
a persisted Avatar position choice (`ui_avatar_position`, Top by default).
|
||||
`AvatarNavigationHost` owns one 420ms open / 320ms close animation using
|
||||
`easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
|
||||
its controls fade in after the expansion starts. Top moves the content pane
|
||||
down through `AvatarNavigationBody`, keeping bottom navigation stationary.
|
||||
Bottom moves the avatar upward into the card's bottom-right corner and
|
||||
overlays the list immediately above navigation. Both use the Files gutter.
|
||||
The floating rail beside a bottom avatar shows text only on the selected
|
||||
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
|
||||
with the avatar inside the trailing end. Its popup expands upward with
|
||||
full screen width, rounded top corners and no gap above the bar; the
|
||||
avatar stays in the bar, and Search keeps its inline position.
|
||||
The attached popup has no outer border or shadow, blending into the bar.
|
||||
All bottom popups order navigation (including Settings), current account,
|
||||
then expanded secondary accounts and Add/Manage Account controls.
|
||||
The iOS shell places Upload and the top avatar on the large-title row.
|
||||
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
|
||||
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
|
||||
shows the plus-and-Upload button. Upload uses the folder icon's tonal
|
||||
`accentSoft` background and `accentText` foreground. Search stays below.
|
||||
Upload uses the compact 32px size to match the visible top avatar.
|
||||
On iOS the accent picker hides the unsupported wallpaper color option;
|
||||
the accent subtitle, swatch and selection always reflect the seed color.
|
||||
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
|
||||
A shared shell animation keeps separately mounted tab headers in sync
|
||||
during Auto tab changes; reduced motion skips the transition.
|
||||
Avatar navigation and its bottom satellite share the bottom bar's frost
|
||||
toggle, blur and opacity, including throughout the popup animation.
|
||||
The setting is labelled "Frosted glass" and also controls both media
|
||||
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
|
||||
without a backdrop filter; On uses the shared blur and tint opacity.
|
||||
Search starts inside the rail when a bottom avatar occupies the satellite;
|
||||
its row slot smoothly collapses as Search fades into that satellite while
|
||||
the avatar moves into the popup. Closing reverses the same path. The card
|
||||
retains hidden tabs, Settings, account switching, Add Account, and Manage
|
||||
Accounts. Account controls scroll on short screens. Tap outside, tap the
|
||||
popup avatar, or system Back to close. Reduced motion opens/closes without
|
||||
the transition. Drawer gestures are disabled in avatar mode. Standalone
|
||||
top bars outside the shell retain `showGeneralDialog` as a fallback.
|
||||
`ShellAvatarButton` keeps its vertical-swipe account shortcuts in either
|
||||
position. Its onTap override must have a matching tooltip/semantics label.
|
||||
Also added
|
||||
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||||
second search row under the way iOS's `search:` slot does, so an inline
|
||||
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||||
@@ -311,11 +369,13 @@ blocks are noted where they matter:
|
||||
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
||||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||
for the video player's seek bar, plus a perpetually-animated
|
||||
for the non-iOS video player's seek bar, plus a perpetually-animated
|
||||
`SeekBarPreview` wrapper used by the Settings style picker so every
|
||||
preview always matches the real widget exactly (same painter, just fed
|
||||
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
||||
forking the painter.
|
||||
iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style
|
||||
picker. Scrubbing pauses playback temporarily and restores its prior state.
|
||||
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
||||
back button + filename, the bottom action bar, the video transport
|
||||
controls) all share one small hand-rolled icon-button pattern
|
||||
@@ -354,11 +414,11 @@ blocks are noted where they matter:
|
||||
|
||||
## Text/markdown viewer
|
||||
|
||||
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; every text file opens read-only (plain text as-is, Markdown rendered) and follows Edit -> Save: an Edit/Preview toggle and, once dirty, a Save button live in the viewer's top bar as round icon-only `NooButton`s (`iconOnly`, 36px; a lone button is primary, with two Edit/Preview is secondary and Save primary; Save becomes a primary circle with a spinner while it saves). Offline copies are read-only with no buttons. The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||
|
||||
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
||||
|
||||
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with the same Edit/Preview toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||
|
||||
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||
|
||||
|
||||
@@ -7,15 +7,44 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; };
|
||||
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
||||
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; };
|
||||
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; };
|
||||
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; };
|
||||
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
|
||||
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
|
||||
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; };
|
||||
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; };
|
||||
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; };
|
||||
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
|
||||
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; };
|
||||
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
||||
FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -26,9 +55,27 @@
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = F9BE9B2F18B10324D7B84776;
|
||||
remoteInfo = ShareExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -42,26 +89,59 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = "<group>"; };
|
||||
0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = "<group>"; };
|
||||
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
||||
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
||||
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = "<group>"; };
|
||||
308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = "<group>"; };
|
||||
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = "<group>"; };
|
||||
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = "<group>"; };
|
||||
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = "<group>"; };
|
||||
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = "<group>"; };
|
||||
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = "<group>"; };
|
||||
857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = "<group>"; };
|
||||
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = "<group>"; };
|
||||
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = "<group>"; };
|
||||
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = "<group>"; };
|
||||
C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
807C81744907462A25A6C1F8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -73,14 +153,62 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2B7F16B0F0EA2C5A842ED860 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0AE6F681A3100A461B4A5318 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3664F08C433ECEB00D1C4BDB /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */,
|
||||
49213989F0FA1B83B0B8A523 /* Info.plist */,
|
||||
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
|
||||
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
|
||||
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */,
|
||||
);
|
||||
name = ShareExtension;
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5055E53DC55C0BCABD5F04F4 /* Native */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8064CC1652B8F0A44E371638 /* TransferManager.swift */,
|
||||
901C0CDB4FB819302E2A7563 /* NativeServices.swift */,
|
||||
DDC89BA8A42212F55851CBA1 /* SyncEngine */,
|
||||
);
|
||||
name = Native;
|
||||
path = Native;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5DF0A9032A8500B67D6C89AE /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */,
|
||||
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */,
|
||||
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */,
|
||||
1F2C956C98F58A97B5595307 /* SharedAccount.swift */,
|
||||
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */,
|
||||
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */,
|
||||
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */,
|
||||
);
|
||||
name = Shared;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -100,6 +228,9 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
5DF0A9032A8500B67D6C89AE /* Shared */,
|
||||
3664F08C433ECEB00D1C4BDB /* ShareExtension */,
|
||||
A5B80AB7F4AB17F90B745D0D /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -108,6 +239,7 @@
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
405A5408F4071B4D12F08B21 /* ShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -124,10 +256,35 @@
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
5055E53DC55C0BCABD5F04F4 /* Native */,
|
||||
857A0E7637776A71AFBB0614 /* Runner.entitlements */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5B80AB7F4AB17F90B745D0D /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2B7F16B0F0EA2C5A842ED860 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DDC89BA8A42212F55851CBA1 /* SyncEngine */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */,
|
||||
A39E02713460B6C7875F3278 /* SyncStore.swift */,
|
||||
308A73EC19D196226AB395DC /* DavSyncClient.swift */,
|
||||
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */,
|
||||
C028B2E4E2874BB74498EABD /* SyncRunner.swift */,
|
||||
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */,
|
||||
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */,
|
||||
);
|
||||
name = SyncEngine;
|
||||
path = SyncEngine;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -152,6 +309,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
@@ -162,6 +320,7 @@
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
@@ -171,6 +330,23 @@
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
F9BE9B2F18B10324D7B84776 /* ShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||
buildPhases = (
|
||||
3CD40C8747E650E6F817CDDB /* Sources */,
|
||||
807C81744907462A25A6C1F8 /* Frameworks */,
|
||||
4EDBC7D61EA0C0974DA27BD6 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = ShareExtension;
|
||||
productName = ShareExtension;
|
||||
productReference = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -209,6 +385,7 @@
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
F9BE9B2F18B10324D7B84776 /* ShareExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -221,6 +398,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EDBC7D61EA0C0974DA27BD6 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -274,6 +458,24 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3CD40C8747E650E6F817CDDB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */,
|
||||
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */,
|
||||
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */,
|
||||
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */,
|
||||
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */,
|
||||
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */,
|
||||
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
||||
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
||||
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
|
||||
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -284,6 +486,22 @@
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */,
|
||||
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */,
|
||||
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */,
|
||||
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */,
|
||||
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */,
|
||||
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */,
|
||||
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
|
||||
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
|
||||
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */,
|
||||
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */,
|
||||
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */,
|
||||
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */,
|
||||
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */,
|
||||
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */,
|
||||
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */,
|
||||
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -295,6 +513,12 @@
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = ShareExtension;
|
||||
target = F9BE9B2F18B10324D7B84776 /* ShareExtension */;
|
||||
targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
@@ -360,7 +584,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -375,6 +599,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -437,6 +662,89 @@
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
3B057BF66761E30DEABF8B30 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
6BF252CEA7A55024A2F58A36 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6FB3F72FB5A94DE254FF4FBF /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -486,7 +794,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -537,7 +845,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -554,6 +862,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -576,6 +885,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -624,6 +934,16 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6BF252CEA7A55024A2F58A36 /* Release */,
|
||||
6FB3F72FB5A94DE254FF4FBF /* Debug */,
|
||||
3B057BF66761E30DEABF8B30 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@@ -7,10 +8,52 @@ import UIKit
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
TransferManager.shared.reconnect()
|
||||
// BGTask handlers have to be registered before launch finishes.
|
||||
SyncCoordinator.shared.registerBackgroundTasks()
|
||||
SyncCoordinator.shared.registerNotificationCategories()
|
||||
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
SyncCoordinator.shared.scheduleBackgroundWork()
|
||||
return launched
|
||||
}
|
||||
|
||||
/// Taps on a sync-conflict notification's "Keep local" / "Use server".
|
||||
override func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return }
|
||||
super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)
|
||||
}
|
||||
|
||||
/// A transfer summary ("Downloaded 1 file") usually finishes while the app
|
||||
/// is open; without this iOS delivers it silently to Notification Center
|
||||
/// instead of showing the banner.
|
||||
override func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier {
|
||||
TransferManager.shared.backgroundCompletionHandler = completionHandler
|
||||
} else {
|
||||
super.application(
|
||||
application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger())
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -53,6 +53,26 @@
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NooKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>dev.ayushya.noo.sync.refresh</string>
|
||||
<string>dev.ayushya.noo.sync.processing</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Noo needs access to your photos and videos so you can choose which ones to upload to your server.</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import Flutter
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Holds the one native-side listener of an `EventChannel` so Swift code can
|
||||
/// push events to Dart whenever they happen.
|
||||
final class SinkStreamHandler: NSObject, FlutterStreamHandler {
|
||||
var sink: FlutterEventSink?
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
sink = events
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
sink = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to
|
||||
/// (Android's lives in `MainActivity.kt`). Upload, download and share (the
|
||||
/// receiving half of the Share Extension) are implemented so far; the rest
|
||||
/// still throw `MissingPluginException`, which the Dart side treats as
|
||||
/// "unavailable" (`lib/services/native_channel.dart`).
|
||||
enum NativeServices {
|
||||
private static let uploadStatus = SinkStreamHandler()
|
||||
|
||||
static func register(messenger: FlutterBinaryMessenger) {
|
||||
FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger)
|
||||
.setMethodCallHandler { call, result in
|
||||
guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) }
|
||||
handle(call, result) { args in
|
||||
let files = try parseJSONList(args["files"])
|
||||
try TransferManager.shared.startUpload(
|
||||
serverUrl: try string(args, "serverUrl"),
|
||||
username: try string(args, "username"),
|
||||
authHeader: try string(args, "authHeader"),
|
||||
files: files.compactMap { entry in
|
||||
guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else {
|
||||
return nil
|
||||
}
|
||||
return UploadFile(uri: uri, name: name)
|
||||
},
|
||||
remoteFolder: (args["remoteFolder"] as? String) ?? "/"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger)
|
||||
.setMethodCallHandler { call, result in
|
||||
guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) }
|
||||
handle(call, result) { args in
|
||||
let items = try parseJSONList(args["files"])
|
||||
try TransferManager.shared.startDownload(
|
||||
serverUrl: try string(args, "serverUrl"),
|
||||
username: try string(args, "username"),
|
||||
authHeader: try string(args, "authHeader"),
|
||||
items: items.compactMap { entry in
|
||||
guard let path = entry["path"] as? String, let name = entry["name"] as? String else {
|
||||
return nil
|
||||
}
|
||||
return (path: path, name: name)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger)
|
||||
.setStreamHandler(uploadStatus)
|
||||
TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in
|
||||
uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed])
|
||||
}
|
||||
TransferManager.shared.reconnect()
|
||||
registerShare(messenger: messenger)
|
||||
registerShareAccount(messenger: messenger)
|
||||
registerSync(messenger: messenger)
|
||||
}
|
||||
|
||||
// MARK: - Device sync
|
||||
|
||||
/// Sends the current sync status the moment Dart starts listening, then
|
||||
/// every change - same as Android's `SyncStatusBus.subscribe`.
|
||||
private final class SyncStatusStream: NSObject, FlutterStreamHandler {
|
||||
var sink: FlutterEventSink?
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
sink = events
|
||||
let coordinator = SyncCoordinator.shared
|
||||
events(coordinator.statusMap(coordinator.bus.snapshot()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
sink = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let syncStatusStream = SyncStatusStream()
|
||||
/// Serial, so status events reach Dart in the order they happened even
|
||||
/// though building each one reads the state from disk.
|
||||
private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility)
|
||||
|
||||
/// `sync_service`: the same methods Android's `MainActivity.kt` serves,
|
||||
/// backed by `SyncCoordinator`/`SyncRunner`.
|
||||
private static func registerSync(messenger: FlutterBinaryMessenger) {
|
||||
let coordinator = SyncCoordinator.shared
|
||||
|
||||
FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger)
|
||||
.setMethodCallHandler { call, result in
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
switch call.method {
|
||||
case "reschedule":
|
||||
handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) }
|
||||
case "cancel":
|
||||
// `forget` (default true) = signed out / removed; false = only the
|
||||
// background job is off - credentials stay for manual runs and
|
||||
// conflict actions.
|
||||
handle(call, result) { _ in
|
||||
coordinator.cancel(
|
||||
accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true)
|
||||
}
|
||||
case "syncNow":
|
||||
handle(call, result) { _ in
|
||||
let force = args["force"] as? Bool ?? true
|
||||
coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool)
|
||||
}
|
||||
case "getSyncStatus":
|
||||
let accountId = args["accountId"] as? String
|
||||
syncStatusQueue.async {
|
||||
let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId)
|
||||
DispatchQueue.main.async { result(map) }
|
||||
}
|
||||
case "resolveConflict":
|
||||
handle(call, result) { _ in
|
||||
let conflict = SyncConflict(
|
||||
accountId: try string(args, "accountId"), fileId: try string(args, "fileId"),
|
||||
remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"),
|
||||
name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent)
|
||||
let creds = SyncCredentials(
|
||||
serverUrl: try string(args, "serverUrl"), username: try string(args, "username"),
|
||||
authHeader: try string(args, "authHeader"))
|
||||
let resolution = try string(args, "resolution")
|
||||
// Fire and forget, like Android: the status stream reports the result.
|
||||
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
|
||||
}
|
||||
case "removeLocalSync":
|
||||
guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else {
|
||||
return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil))
|
||||
}
|
||||
Task {
|
||||
await coordinator.removeLocalSync(accountId: accountId, path: path)
|
||||
await MainActor.run { result(nil) }
|
||||
}
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger)
|
||||
.setStreamHandler(syncStatusStream)
|
||||
coordinator.bus.onChange = { status in
|
||||
syncStatusQueue.async {
|
||||
let map = coordinator.statusMap(status)
|
||||
DispatchQueue.main.async { syncStatusStream.sink?(map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The account credentials + settings every sync call carries, as a config.
|
||||
private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig {
|
||||
var folders: [String] = []
|
||||
if let json = args["folders"] as? String, let data = json.data(using: .utf8),
|
||||
let list = try? JSONSerialization.jsonObject(with: data) as? [String]
|
||||
{
|
||||
folders = list
|
||||
}
|
||||
return SyncAccountConfig(
|
||||
accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"),
|
||||
username: try string(args, "username"), authHeader: try string(args, "authHeader"),
|
||||
folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true,
|
||||
intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true)
|
||||
}
|
||||
|
||||
// MARK: - Account for the Share Extension
|
||||
|
||||
/// `share_account`: Dart hands native every account that can upload, which
|
||||
/// one is active, and the app-lock settings, so the Share Extension - a
|
||||
/// separate process with no Flutter engine - can list folders and upload as
|
||||
/// them. Stored in the shared Keychain group.
|
||||
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
|
||||
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
|
||||
.setMethodCallHandler { call, result in
|
||||
switch call.method {
|
||||
case "setAccounts":
|
||||
handle(call, result) { args in
|
||||
guard let json = args["accounts"] as? String,
|
||||
let data = json.data(using: .utf8),
|
||||
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
|
||||
else { throw TransferError(message: "Bad accounts payload.") }
|
||||
try SharedAccountStore.save(accounts)
|
||||
NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none")
|
||||
}
|
||||
case "clearAccount":
|
||||
SharedAccountStore.clear()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Extension inbox
|
||||
|
||||
private static let shareEvents = SinkStreamHandler()
|
||||
|
||||
/// `share_intent`: files the Share Extension left in the App Group inbox.
|
||||
/// Cold start asks once (`getInitialShare`); a share that arrives while the
|
||||
/// app is running is pushed when it next becomes active (the extension
|
||||
/// can't signal the app any other way).
|
||||
private static func registerShare(messenger: FlutterBinaryMessenger) {
|
||||
FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger)
|
||||
.setMethodCallHandler { call, result in
|
||||
guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) }
|
||||
result(consumeShared().map(encode))
|
||||
}
|
||||
FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger)
|
||||
.setStreamHandler(shareEvents)
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||
) { _ in
|
||||
TransferManager.shared.deliverExtensionUploads()
|
||||
guard let sink = shareEvents.sink else { return }
|
||||
let items = consumeShared()
|
||||
if !items.isEmpty { sink(items.map(encode)) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func consumeShared() -> [SharedItem] {
|
||||
guard let root = AppGroup.containerURL else { return [] }
|
||||
return SharedInbox.consume(in: root)
|
||||
}
|
||||
|
||||
private static func encode(_ item: SharedItem) -> [String: Any] {
|
||||
[
|
||||
"uri": URL(fileURLWithPath: item.path).absoluteString,
|
||||
"name": item.name,
|
||||
"mimeType": item.mimeType ?? NSNull(),
|
||||
"size": item.size,
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Argument helpers
|
||||
|
||||
/// Runs [work] with the call's argument map, turning a thrown error into
|
||||
/// the `FlutterError` Dart's `catch` shows the user.
|
||||
private static func handle(
|
||||
_ call: FlutterMethodCall,
|
||||
_ result: @escaping FlutterResult,
|
||||
work: ([String: Any]) throws -> Void
|
||||
) {
|
||||
guard let args = call.arguments as? [String: Any] else {
|
||||
return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil))
|
||||
}
|
||||
do {
|
||||
try work(args)
|
||||
result(nil)
|
||||
} catch {
|
||||
result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
private static func string(_ args: [String: Any], _ key: String) throws -> String {
|
||||
guard let value = args[key] as? String, !value.isEmpty else {
|
||||
throw TransferError(message: "Missing \(key).")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// The Dart services pass their file list as one JSON string.
|
||||
private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] {
|
||||
guard let json = value as? String,
|
||||
let data = json.data(using: .utf8),
|
||||
let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||
else {
|
||||
throw TransferError(message: "Missing file list.")
|
||||
}
|
||||
return list
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
|
||||
/// Credentials for one account's WebDAV root.
|
||||
struct SyncCredentials {
|
||||
let serverUrl: String
|
||||
let username: String
|
||||
let authHeader: String
|
||||
}
|
||||
|
||||
/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item),
|
||||
/// a recursive walk, and GET/PUT of a single file. Foreground
|
||||
/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a
|
||||
/// background session, so a run can walk, diff and decide in one go.
|
||||
///
|
||||
/// Authenticates by each request's own `Authorization` header only (no cookies,
|
||||
/// no credential cache): two accounts can share a server, and one account's
|
||||
/// session must never answer for another.
|
||||
final class DavSyncClient {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = DavSyncClient.makeSession()) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
static func makeSession() -> URLSession {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.httpShouldSetCookies = false
|
||||
config.httpCookieAcceptPolicy = .never
|
||||
config.urlCredentialStorage = nil
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
config.timeoutIntervalForRequest = 30
|
||||
config.timeoutIntervalForResource = 15 * 60
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - PROPFIND
|
||||
|
||||
private static let propfindBody = """
|
||||
<?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>
|
||||
"""
|
||||
|
||||
/// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the
|
||||
/// folder is genuinely gone) is an empty list; anything else that goes wrong
|
||||
/// throws `SyncRemoteUnavailable`.
|
||||
func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] {
|
||||
try await propfind(creds, path: path, depth: "1", skipSelf: true)
|
||||
}
|
||||
|
||||
/// Depth-0 PROPFIND of [path] itself - nil on a clean 404.
|
||||
func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? {
|
||||
try await propfind(creds, path: path, depth: "0", skipSelf: false).first
|
||||
}
|
||||
|
||||
private func propfind(
|
||||
_ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool
|
||||
) async throws -> [SyncRemoteEntry] {
|
||||
var clean = path.trimmingCharacters(in: .whitespaces)
|
||||
if !clean.hasPrefix("/") { clean = "/" + clean }
|
||||
guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean)
|
||||
else { throw SyncRemoteUnavailable(message: "Invalid server address.") }
|
||||
// Collections are addressed with a trailing slash.
|
||||
if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PROPFIND"
|
||||
request.setValue(depth, forHTTPHeaderField: "Depth")
|
||||
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = Self.propfindBody.data(using: .utf8)
|
||||
|
||||
let data: Data
|
||||
let status: Int
|
||||
do {
|
||||
let (body, response) = try await session.data(for: request)
|
||||
data = body
|
||||
status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
} catch {
|
||||
try Self.rethrowIfCancelled(error)
|
||||
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)")
|
||||
}
|
||||
if status == 404 { return [] } // genuinely gone
|
||||
guard status == 207 else {
|
||||
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)")
|
||||
}
|
||||
return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf)
|
||||
}
|
||||
|
||||
/// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat
|
||||
/// manifest. Throws if *any* level fails - a partial manifest would look
|
||||
/// like the missing part had been deleted on the server.
|
||||
func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] {
|
||||
var result: [SyncRemoteEntry] = []
|
||||
var queue = [root]
|
||||
var index = 0
|
||||
while index < queue.count {
|
||||
try Task.checkCancellation()
|
||||
let children = try await propfindChildren(creds, path: queue[index])
|
||||
index += 1
|
||||
for child in children {
|
||||
result.append(child)
|
||||
if child.isFolder { queue.append(child.path) }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - GET / PUT
|
||||
|
||||
/// Downloads [remotePath] to [destination]. Written to a temp file first and
|
||||
/// moved into place, so an interrupted download never leaves a truncated
|
||||
/// file where a good one used to be. False on any failure.
|
||||
func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool {
|
||||
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
||||
else { return false }
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||
do {
|
||||
let (temp, response) = try await session.download(for: request)
|
||||
guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else {
|
||||
try? FileManager.default.removeItem(at: temp)
|
||||
return false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
var isDirectory: ObjCBool = false
|
||||
if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
|
||||
// Never replace a folder with a file, and replace a file atomically:
|
||||
// the old copy stays until the new one is fully in place.
|
||||
guard !isDirectory.boolValue else {
|
||||
try? fm.removeItem(at: temp)
|
||||
return false
|
||||
}
|
||||
_ = try fm.replaceItemAt(destination, withItemAt: temp)
|
||||
} else {
|
||||
try fm.moveItem(at: temp, to: destination)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
try Self.rethrowIfCancelled(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// PUTs [source] to [remotePath]. False on any failure.
|
||||
func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool {
|
||||
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
||||
else { return false }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||
do {
|
||||
let (_, response) = try await session.upload(for: request, fromFile: source)
|
||||
guard let status = (response as? HTTPURLResponse)?.statusCode else { return false }
|
||||
return (200..<300).contains(status)
|
||||
} catch {
|
||||
try Self.rethrowIfCancelled(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled run must stop, not be mistaken for a flaky network.
|
||||
private static func rethrowIfCancelled(_ error: Error) throws {
|
||||
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's
|
||||
/// unit-tested.
|
||||
enum DavSyncParser {
|
||||
private struct Raw {
|
||||
var href = ""
|
||||
var etag = ""
|
||||
var modified = ""
|
||||
var length = ""
|
||||
var fileId = ""
|
||||
var isCollection = false
|
||||
}
|
||||
|
||||
private final class Delegate: NSObject, XMLParserDelegate {
|
||||
var responses: [Raw] = []
|
||||
var sawMultistatus = false
|
||||
private var current: Raw?
|
||||
private var text = ""
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
text = ""
|
||||
if elementName == "multistatus" { sawMultistatus = true }
|
||||
if elementName == "response" { current = Raw() }
|
||||
if elementName == "collection" { current?.isCollection = true }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
text += string
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch elementName {
|
||||
case "href": current?.href = value
|
||||
case "getetag": current?.etag = value
|
||||
case "getlastmodified": current?.modified = value
|
||||
case "getcontentlength": current?.length = value
|
||||
case "fileid": current?.fileId = value
|
||||
case "response":
|
||||
if let current { responses.append(current) }
|
||||
current = nil
|
||||
default: break
|
||||
}
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
private static let httpDate: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for
|
||||
/// anything that isn't a complete, plausible answer - malformed or
|
||||
/// truncated XML, no `multistatus` envelope, no responses at all (a 207
|
||||
/// always has at least the requested resource), or an href that isn't
|
||||
/// under this account's files root or tries to climb out of it. A partial
|
||||
/// manifest would look like the missing part was deleted on the server
|
||||
/// (and an empty one like the whole path was), so the caller must treat
|
||||
/// those as "couldn't reach the server", never as data.
|
||||
static func entries(
|
||||
from xml: Data, username: String, requestedPath: String, skipSelf: Bool
|
||||
) throws -> [SyncRemoteEntry] {
|
||||
let delegate = Delegate()
|
||||
let parser = XMLParser(data: xml)
|
||||
parser.shouldProcessNamespaces = true
|
||||
parser.delegate = delegate
|
||||
guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else {
|
||||
throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer")
|
||||
}
|
||||
|
||||
let marker = "/remote.php/dav/files/\(username)"
|
||||
let selfPath = trimTrailingSlashes(requestedPath)
|
||||
|
||||
var entries: [SyncRemoteEntry] = []
|
||||
for raw in delegate.responses {
|
||||
let decoded = raw.href.removingPercentEncoding ?? raw.href
|
||||
guard let range = decoded.range(of: marker) else {
|
||||
throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root")
|
||||
}
|
||||
let hrefPath = String(decoded[range.upperBound...])
|
||||
// A well-formed path continues the marker at a component boundary and
|
||||
// never contains a dot component.
|
||||
guard hrefPath.isEmpty || hrefPath.hasPrefix("/"),
|
||||
!hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." })
|
||||
else {
|
||||
throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path")
|
||||
}
|
||||
let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath)
|
||||
if skipSelf && normalized == selfPath { continue } // the folder itself, not a child
|
||||
entries.append(
|
||||
SyncRemoteEntry(
|
||||
path: normalized.isEmpty ? "/" : normalized,
|
||||
fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId,
|
||||
etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
|
||||
lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0,
|
||||
size: Int64(raw.length) ?? 0,
|
||||
isFolder: raw.isCollection))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func trimTrailingSlashes(_ path: String) -> String {
|
||||
var p = path
|
||||
while p.hasSuffix("/") { p.removeLast() }
|
||||
return p
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Every account's sync settings and credentials, kept in the Keychain (the
|
||||
/// auth header is a secret) as one JSON item. A background run - which may
|
||||
/// start while the app isn't running - reads these to know what to sync and
|
||||
/// as whom, and a conflict notification's action looks the account up here
|
||||
/// instead of carrying credentials in the notification itself.
|
||||
///
|
||||
/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background
|
||||
/// task can fire with the phone locked.
|
||||
///
|
||||
/// All accounts live in one item, so a write must never be built on a
|
||||
/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and
|
||||
/// say so) rather than save a map holding only one account, and an update
|
||||
/// replaces the item in place instead of deleting it first.
|
||||
final class SyncConfigStore {
|
||||
private struct KeychainError: Error { let status: OSStatus }
|
||||
|
||||
private let service: String
|
||||
private let account = "configs"
|
||||
private let lock = NSLock()
|
||||
|
||||
init(service: String = "dev.ayushya.noo.sync-configs") {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// Empty if there are none - or if the item can't be read, in which case
|
||||
/// nothing runs in the background until it can.
|
||||
func all() -> [SyncAccountConfig] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId }
|
||||
}
|
||||
|
||||
func config(for accountId: String) -> SyncAccountConfig? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (try? load())?[accountId]
|
||||
}
|
||||
|
||||
/// False if it couldn't be stored (the previous contents are untouched).
|
||||
@discardableResult
|
||||
func upsert(_ config: SyncAccountConfig) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
var configs = try load()
|
||||
configs[config.accountId] = config
|
||||
try save(configs)
|
||||
return true
|
||||
} catch {
|
||||
NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(accountId: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
var configs = try load()
|
||||
guard configs.removeValue(forKey: accountId) != nil else { return true }
|
||||
try save(configs)
|
||||
return true
|
||||
} catch {
|
||||
NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
SecItemDelete(baseQuery() as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
/// `[:]` only when nothing is stored yet; any other failure throws.
|
||||
private func load() throws -> [String: SyncAccountConfig] {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return [:] }
|
||||
guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) }
|
||||
return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data)
|
||||
}
|
||||
|
||||
private func save(_ configs: [String: SyncAccountConfig]) throws {
|
||||
guard !configs.isEmpty else {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) }
|
||||
return
|
||||
}
|
||||
let data = try JSONEncoder().encode(configs) // before touching the item
|
||||
let status = SecItemUpdate(
|
||||
baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var query = baseQuery()
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let added = SecItemAdd(query as CFDictionary, nil)
|
||||
if added != errSecSuccess { throw KeychainError(status: added) }
|
||||
} else if status != errSecSuccess {
|
||||
throw KeychainError(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import BackgroundTasks
|
||||
import Network
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
/// Whether the network currently allows a sync run: any connection normally,
|
||||
/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) -
|
||||
/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint,
|
||||
/// which a `BGTask` doesn't offer.
|
||||
enum NetworkGate {
|
||||
static func allows(wifiOnly: Bool) async -> Bool {
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate")
|
||||
return await withCheckedContinuation { continuation in
|
||||
var answered = false // only touched on `queue`
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard !answered else { return }
|
||||
answered = true
|
||||
monitor.cancel()
|
||||
let connected = path.status == .satisfied
|
||||
continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive))
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs an action at most once - for completing a `BGTask`, which must be
|
||||
/// told it's done exactly one time whether the work finished or the system's
|
||||
/// time ran out first.
|
||||
final class OnceGate {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
private let action: (Bool) -> Void
|
||||
|
||||
init(_ action: @escaping (Bool) -> Void) {
|
||||
self.action = action
|
||||
}
|
||||
|
||||
func finish(_ success: Bool) {
|
||||
lock.lock()
|
||||
if done {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
done = true
|
||||
lock.unlock()
|
||||
action(success)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side
|
||||
/// asks for, the periodic background runs iOS grants through
|
||||
/// `BGTaskScheduler`, conflict resolution, and the notifications around them.
|
||||
/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s
|
||||
/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`.
|
||||
///
|
||||
/// Every run - foreground or background - goes through `startRun`, so one
|
||||
/// per-account record knows what's running, "Sync now" can supersede it and a
|
||||
/// quiet check can leave it alone, and signing out or removing a path can
|
||||
/// cancel it. Runs are also serialised against each other (and against
|
||||
/// removal) by one mutex, and carry the account's *generation*: cancelling or
|
||||
/// removing bumps it, so a run that was only queued behind another never
|
||||
/// starts for an account that's gone in the meantime.
|
||||
///
|
||||
/// iOS decides *when* background work runs (typically while charging, on
|
||||
/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the
|
||||
/// earliest it may start, not a schedule, and a run is cut off when the
|
||||
/// system's time budget ends (the runner saves its state as it goes).
|
||||
final class SyncCoordinator {
|
||||
static let shared = SyncCoordinator()
|
||||
|
||||
static let refreshTaskId = "dev.ayushya.noo.sync.refresh"
|
||||
static let processingTaskId = "dev.ayushya.noo.sync.processing"
|
||||
|
||||
static let conflictCategory = "dev.ayushya.noo.sync.conflict"
|
||||
static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal"
|
||||
static let useServerAction = "dev.ayushya.noo.sync.useServer"
|
||||
|
||||
let store: SyncStore
|
||||
let bus: SyncStatusBus
|
||||
let configs: SyncConfigStore
|
||||
private let runner: SyncRunner
|
||||
|
||||
/// One run at a time, app-wide (see `AsyncMutex`).
|
||||
private let mutex = AsyncMutex()
|
||||
private let lock = NSLock()
|
||||
private var running: [String: (token: UUID, task: Task<Void, Never>)] = [:]
|
||||
private var generations: [String: Int] = [:]
|
||||
|
||||
init(
|
||||
store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(),
|
||||
configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient()
|
||||
) {
|
||||
self.store = store
|
||||
self.bus = bus
|
||||
self.configs = configs
|
||||
self.runner = SyncRunner(client: client, store: store, bus: bus)
|
||||
}
|
||||
|
||||
// MARK: - Settings from Dart
|
||||
|
||||
/// Brings the background job in line with [config] (called whenever the
|
||||
/// synced paths, account or network setting changes). A config with no
|
||||
/// folders means this account has nothing to sync.
|
||||
func reschedule(_ config: SyncAccountConfig) {
|
||||
if config.folders.isEmpty {
|
||||
cancel(accountId: config.accountId, forget: true)
|
||||
return
|
||||
}
|
||||
configs.upsert(config)
|
||||
scheduleBackgroundWork()
|
||||
}
|
||||
|
||||
/// [forget] true: the account was removed or signed out - stop its run and
|
||||
/// drop its settings and credentials. [forget] false: only background sync
|
||||
/// was turned off (manual refresh is still possible) - the credentials stay,
|
||||
/// since a conflict notification's actions and the next "Sync now" need
|
||||
/// them, and a run in progress is left to finish.
|
||||
func cancel(accountId: String, forget: Bool = true) {
|
||||
if forget {
|
||||
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||
configs.remove(accountId: accountId)
|
||||
} else if var config = configs.config(for: accountId) {
|
||||
config.intervalMinutes = nil
|
||||
configs.upsert(config)
|
||||
}
|
||||
scheduleBackgroundWork()
|
||||
}
|
||||
|
||||
/// A one-off run. [force] is an explicit "Sync now" / newly added path:
|
||||
/// a full walk, a summary notification, and it supersedes any run already
|
||||
/// going for this account; without it (pull-to-refresh, foreground timer,
|
||||
/// app resume) it's a quiet check that never interrupts a run in progress.
|
||||
/// [wifiOnly] non-nil means "respect the Wi-Fi-only setting".
|
||||
func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) {
|
||||
if config.folders.isEmpty { return }
|
||||
// Keep the background settings this call doesn't carry.
|
||||
var stored = config
|
||||
if let existing = configs.config(for: config.accountId) {
|
||||
stored.intervalMinutes = existing.intervalMinutes
|
||||
stored.wifiOnly = existing.wifiOnly
|
||||
}
|
||||
configs.upsert(stored)
|
||||
startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly)
|
||||
}
|
||||
|
||||
/// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that
|
||||
/// are only waiting their turn from ever starting.
|
||||
private func cancelRun(accountId: String, invalidateQueued: Bool) {
|
||||
lock.lock()
|
||||
if invalidateQueued { generations[accountId, default: 0] += 1 }
|
||||
running[accountId]?.task.cancel()
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func generation(of accountId: String) -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return generations[accountId] ?? 0
|
||||
}
|
||||
|
||||
/// Starts a tracked run, or - for a non-forced one when this account is
|
||||
/// already running - returns nil and leaves the current run alone.
|
||||
@discardableResult
|
||||
private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task<Void, Never>? {
|
||||
lock.lock()
|
||||
if let existing = running[config.accountId] {
|
||||
if force {
|
||||
existing.task.cancel()
|
||||
} else {
|
||||
lock.unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let token = UUID()
|
||||
let generation = generations[config.accountId] ?? 0
|
||||
let task = Task { [weak self] in
|
||||
await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation)
|
||||
self?.finished(accountId: config.accountId, token: token)
|
||||
}
|
||||
running[config.accountId] = (token, task)
|
||||
lock.unlock()
|
||||
return task
|
||||
}
|
||||
|
||||
private func finished(accountId: String, token: UUID) {
|
||||
lock.lock()
|
||||
if running[accountId]?.token == token { running[accountId] = nil }
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Turning sync off for [path]: stop this account's run first and do the
|
||||
/// removal under the same mutex as runs - a run still holding the old
|
||||
/// state would otherwise write it back over the removal (and a transfer
|
||||
/// finishing after it would leave a file the state doesn't know about).
|
||||
func removeLocalSync(accountId: String, path: String) async {
|
||||
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||
_ = try? await mutex.withLock {
|
||||
store.removeLocalSync(accountId: accountId, path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride]
|
||||
/// is the account Dart is asking about: the bus only learns an account once
|
||||
/// a run has happened in this process, so on a fresh start the durable
|
||||
/// per-file synced state would otherwise be reported as empty.
|
||||
func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] {
|
||||
let accountId = accountIdOverride ?? status.accountId
|
||||
// Live syncing state belongs to whichever account last synced.
|
||||
let busMatches = status.accountId == nil || status.accountId == accountId
|
||||
let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? []
|
||||
let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? []
|
||||
return [
|
||||
"accountId": accountId as Any,
|
||||
"syncing": busMatches && status.syncing,
|
||||
"syncingFileIds": busMatches ? Array(status.syncingFileIds) : [],
|
||||
"syncedFileIds": synced,
|
||||
"missingRoots": missing,
|
||||
// Conflicts are per account (a file id is only unique within one).
|
||||
"conflicts": status.conflicts.filter { $0.accountId == accountId }.map {
|
||||
[
|
||||
"accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath,
|
||||
"relPath": $0.relPath, "name": $0.name,
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Running
|
||||
|
||||
/// One account's pass, serialised against every other run.
|
||||
private func execute(
|
||||
_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int
|
||||
) async {
|
||||
// A few extra seconds if the app is backgrounded mid-run; if even those
|
||||
// run out, stop this account's run so it saves and ends cleanly.
|
||||
let box = BackgroundTaskBox()
|
||||
box.id = await MainActor.run {
|
||||
UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in
|
||||
self?.cancelRun(accountId: config.accountId, invalidateQueued: false)
|
||||
UIApplication.shared.endBackgroundTask(box.id)
|
||||
}
|
||||
}
|
||||
defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } }
|
||||
|
||||
do {
|
||||
try await mutex.withLock {
|
||||
try Task.checkCancellation()
|
||||
// Gone (signed out / removed) while this was queued?
|
||||
guard self.generation(of: config.accountId) == generation,
|
||||
self.configs.config(for: config.accountId) != nil
|
||||
else { return }
|
||||
if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return }
|
||||
let summary = await runner.run(config, force: force)
|
||||
if summary.changedAnything && (force || config.notify) {
|
||||
SyncNotifications.summary(summary, username: config.username, accountId: config.accountId)
|
||||
}
|
||||
SyncNotifications.conflicts(summary.conflicts, username: config.username)
|
||||
}
|
||||
} catch {
|
||||
// Cancelled while waiting for its turn - nothing ran.
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a conflict from a notification action, using the stored
|
||||
/// credentials for its account.
|
||||
func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool {
|
||||
(try? await mutex.withLock {
|
||||
await runner.resolveConflict(conflict, resolution: resolution, creds: creds)
|
||||
}) ?? false
|
||||
}
|
||||
|
||||
// MARK: - Background tasks
|
||||
|
||||
/// Registers the two `BGTask` handlers. Must run before the app finishes
|
||||
/// launching (see `AppDelegate`).
|
||||
func registerBackgroundTasks() {
|
||||
for id in [Self.refreshTaskId, Self.processingTaskId] {
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in
|
||||
self?.handleBackground(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks iOS for the next background run: a short app-refresh slot and a
|
||||
/// longer processing slot (the system picks when, e.g. overnight on power).
|
||||
/// Nothing is requested when no account has background sync turned on.
|
||||
func scheduleBackgroundWork() {
|
||||
let scheduler = BGTaskScheduler.shared
|
||||
let due = backgroundConfigs()
|
||||
guard let minutes = due.compactMap(\.intervalMinutes).min() else {
|
||||
scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId)
|
||||
scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId)
|
||||
return
|
||||
}
|
||||
// Same floor as Android's periodic work.
|
||||
let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60))
|
||||
|
||||
let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId)
|
||||
refresh.earliestBeginDate = earliest
|
||||
let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId)
|
||||
processing.earliestBeginDate = earliest
|
||||
processing.requiresNetworkConnectivity = true
|
||||
processing.requiresExternalPower = false
|
||||
for request in [refresh, processing] as [BGTaskRequest] {
|
||||
do { try scheduler.submit(request) } catch {
|
||||
NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundConfigs() -> [SyncAccountConfig] {
|
||||
configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty }
|
||||
}
|
||||
|
||||
/// A background slot: runs every account that has background sync on, each
|
||||
/// through the normal tracked path (so it can be cancelled by sign-out, and
|
||||
/// a run already going is left alone). The task is reported complete
|
||||
/// exactly once - when the work ends or when the system's time does.
|
||||
private func handleBackground(_ task: BGTask) {
|
||||
scheduleBackgroundWork() // line up the next one before this one runs
|
||||
task.expirationHandler = startBackgroundSlot { task.setTaskCompleted(success: $0) }
|
||||
}
|
||||
|
||||
/// Runs a background slot and returns its expiration handler.
|
||||
@discardableResult
|
||||
func startBackgroundSlot(completion: @escaping (Bool) -> Void) -> () -> Void {
|
||||
let gate = OnceGate(completion)
|
||||
let accountIds = backgroundConfigs().map(\.accountId)
|
||||
let work = Task { [weak self] in
|
||||
guard let self else { return gate.finish(false) }
|
||||
for config in self.backgroundConfigs() {
|
||||
if Task.isCancelled { break }
|
||||
await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value
|
||||
}
|
||||
gate.finish(!Task.isCancelled)
|
||||
}
|
||||
// The system's time is up: stop the work and the runs it started (each
|
||||
// saves what it has) and report.
|
||||
return { [weak self] in
|
||||
work.cancel()
|
||||
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
|
||||
gate.finish(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
/// Registers the conflict notification's actions. Call once at launch.
|
||||
func registerNotificationCategories() {
|
||||
let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: [])
|
||||
let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: [])
|
||||
let category = UNNotificationCategory(
|
||||
identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: [])
|
||||
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||
}
|
||||
|
||||
/// Handles a tap on a conflict notification's action. Returns whether the
|
||||
/// response was ours (so `AppDelegate` knows not to pass it on).
|
||||
@MainActor
|
||||
func handleNotificationResponse(
|
||||
_ response: UNNotificationResponse, completion: @escaping () -> Void
|
||||
) -> Bool {
|
||||
let content = response.notification.request.content
|
||||
guard content.categoryIdentifier == Self.conflictCategory else { return false }
|
||||
let resolution: String
|
||||
switch response.actionIdentifier {
|
||||
case Self.keepLocalAction: resolution = "local"
|
||||
case Self.useServerAction: resolution = "server"
|
||||
default:
|
||||
completion() // plain tap: just opens the app
|
||||
return true
|
||||
}
|
||||
let info = content.userInfo
|
||||
guard let accountId = info["accountId"] as? String,
|
||||
let fileId = info["fileId"] as? String,
|
||||
let remotePath = info["remotePath"] as? String,
|
||||
let relPath = info["relPath"] as? String,
|
||||
let config = configs.config(for: accountId)
|
||||
else {
|
||||
completion()
|
||||
return true
|
||||
}
|
||||
let conflict = SyncConflict(
|
||||
accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath,
|
||||
name: (relPath as NSString).lastPathComponent)
|
||||
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||
let box = BackgroundTaskBox()
|
||||
box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") {
|
||||
completion()
|
||||
UIApplication.shared.endBackgroundTask(box.id)
|
||||
}
|
||||
Task {
|
||||
let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds)
|
||||
if ok {
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(
|
||||
withIdentifiers: [response.notification.request.identifier])
|
||||
}
|
||||
completion()
|
||||
await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created
|
||||
/// before the identifier exists) can end the task it belongs to.
|
||||
final class BackgroundTaskBox {
|
||||
var id: UIBackgroundTaskIdentifier = .invalid
|
||||
}
|
||||
|
||||
extension SyncAccountConfig {
|
||||
func withNotify(_ notify: Bool) -> SyncAccountConfig {
|
||||
var copy = self
|
||||
copy.notify = notify
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
/// The local notifications sync posts: a summary of what changed, and one per
|
||||
/// conflict (which always posts - it needs a decision) with Keep local / Use
|
||||
/// server actions.
|
||||
enum SyncNotifications {
|
||||
static func summary(_ summary: SyncRunSummary, username: String, accountId: String) {
|
||||
var parts: [String] = []
|
||||
if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") }
|
||||
if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") }
|
||||
if summary.deleted > 0 { parts.append("\(summary.deleted) removed") }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Noo sync · \(username)"
|
||||
content.body = parts.joined(separator: ", ")
|
||||
// One per account, so a newer summary replaces an older one in place.
|
||||
post(content, id: "sync.summary.\(accountId)")
|
||||
}
|
||||
|
||||
static func conflicts(_ conflicts: [SyncConflict], username: String) {
|
||||
for conflict in conflicts {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Sync conflict: \(conflict.name)"
|
||||
content.body = "\(username) - changed both on this device and on the server."
|
||||
content.categoryIdentifier = SyncCoordinator.conflictCategory
|
||||
content.userInfo = [
|
||||
"accountId": conflict.accountId, "fileId": conflict.fileId,
|
||||
"remotePath": conflict.remotePath, "relPath": conflict.relPath,
|
||||
]
|
||||
post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func post(_ content: UNMutableNotificationContent, id: String) {
|
||||
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import Foundation
|
||||
|
||||
/// A local file's size and modification time (ms), the pair sync compares
|
||||
/// against what it recorded.
|
||||
struct LocalStat: Equatable {
|
||||
let size: Int64
|
||||
let mtimeMs: Int64
|
||||
}
|
||||
|
||||
enum LocalFS {
|
||||
/// nil if [url] doesn't exist.
|
||||
static func stat(_ url: URL) -> LocalStat? {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
|
||||
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||
let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0
|
||||
return LocalStat(size: size, mtimeMs: mtime)
|
||||
}
|
||||
|
||||
static func relative(_ path: String) -> String {
|
||||
path.hasPrefix("/") ? String(path.dropFirst()) : path
|
||||
}
|
||||
|
||||
static func trimTrailingSlashes(_ path: String) -> String {
|
||||
var p = path
|
||||
while p.hasSuffix("/") { p.removeLast() }
|
||||
return p
|
||||
}
|
||||
|
||||
/// [rel] under [root], or nil if it isn't safely inside it: empty, with a
|
||||
/// `.`/`..` component, or resolving outside [root]. Every path that came
|
||||
/// from the server goes through this before anything is read, written or
|
||||
/// deleted, so a hostile or buggy server can't point sync at other files.
|
||||
static func resolve(_ rel: String, in root: URL) -> URL? {
|
||||
let parts = rel.split(separator: "/", omittingEmptySubsequences: true)
|
||||
guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil }
|
||||
let url = root.appendingPathComponent(rel)
|
||||
let rootPath = root.standardizedFileURL.path
|
||||
guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// The decisions of a sync pass, kept free of networking so they can be
|
||||
/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its
|
||||
/// helpers - the same rules, so a device behaves the same on either platform.
|
||||
enum SyncDiff {
|
||||
/// Diffs one synced path's remote manifest against the recorded state.
|
||||
/// Callers own actually doing the GET/PUT/delete and updating state.
|
||||
///
|
||||
/// - A file with no recorded state is simply pulled.
|
||||
/// - Server changed and device changed -> conflict (never silently overwrite).
|
||||
/// - Only the server changed -> download. Only the device changed -> upload.
|
||||
/// - Recorded but missing on the device -> pull it back (it's inside a path
|
||||
/// the user asked to sync, so a vanished copy is repaired, not propagated).
|
||||
/// - Recorded for this path but absent from a *successful* listing ->
|
||||
/// deleted on the server, so the local copy goes too. (An unreachable
|
||||
/// server never gets here - the caller skips the path instead.)
|
||||
static func diffFolder(
|
||||
entries: [SyncRemoteEntry],
|
||||
state: [String: SyncFileState],
|
||||
priorFolderFileIds: Set<String>,
|
||||
syncRoot: URL
|
||||
) -> [SyncAction] {
|
||||
var actions: [SyncAction] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
for entry in entries where !entry.isFolder {
|
||||
seen.insert(entry.fileId)
|
||||
let rel = LocalFS.relative(entry.path)
|
||||
let local = LocalFS.stat(syncRoot.appendingPathComponent(rel))
|
||||
|
||||
guard let prior = state[entry.fileId] else {
|
||||
actions.append(.download(entry))
|
||||
continue
|
||||
}
|
||||
let serverChanged = entry.etag != prior.etag
|
||||
let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false
|
||||
|
||||
if local == nil {
|
||||
actions.append(.download(entry))
|
||||
} else if serverChanged && localChanged {
|
||||
actions.append(.conflict(entry, relPath: rel))
|
||||
} else if serverChanged {
|
||||
actions.append(.download(entry))
|
||||
} else if localChanged {
|
||||
actions.append(.upload(relPath: rel, fileId: entry.fileId))
|
||||
}
|
||||
}
|
||||
|
||||
for fileId in priorFolderFileIds where !seen.contains(fileId) {
|
||||
guard let prior = state[fileId] else { continue }
|
||||
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
/// The recorded files that belong to the synced [path]: everything for the
|
||||
/// root ("/"), else the file itself or anything under it - matched on a
|
||||
/// path-component boundary, so "/Docs" never claims "/Docs2" or
|
||||
/// "/Documents".
|
||||
static func priorFileIds(state: [String: SyncFileState], path: String) -> Set<String> {
|
||||
let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path))
|
||||
return Set(
|
||||
state.filter {
|
||||
rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/")
|
||||
}.keys)
|
||||
}
|
||||
|
||||
/// Local paths (as spelled by the server) that more than one remote entry
|
||||
/// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two
|
||||
/// canonically-equivalent Unicode spellings. Downloading either would
|
||||
/// overwrite the other, so callers skip them.
|
||||
static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set<String> {
|
||||
var byKey: [String: [String]] = [:]
|
||||
for entry in entries {
|
||||
let rel = LocalFS.relative(entry.path)
|
||||
byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel)
|
||||
}
|
||||
// Compared as raw bytes: Swift's own `String` equality already treats
|
||||
// canonically equivalent spellings as the same string, which is exactly
|
||||
// the distinction the server preserves and the disk may not.
|
||||
return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 })
|
||||
}
|
||||
|
||||
/// True if every file in [fileIds] is still on disk exactly as recorded
|
||||
/// (same size and mtime): nothing local to upload and nothing missing to
|
||||
/// re-download. Pure local stat calls, no network.
|
||||
static func localMatchesState(
|
||||
state: [String: SyncFileState], fileIds: Set<String>, syncRoot: URL
|
||||
) -> Bool {
|
||||
fileIds.allSatisfy { id in
|
||||
guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else {
|
||||
return false
|
||||
}
|
||||
return local.size == s.size && local.mtimeMs == s.localMTime
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a local directory for every remote folder, so empty folders
|
||||
/// exist on the device too.
|
||||
static func mirrorFolders(
|
||||
entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set<String> = []
|
||||
) {
|
||||
let fm = FileManager.default
|
||||
if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) {
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
for entry in entries where entry.isFolder {
|
||||
let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
// Skip anything unsafe or that would share a local folder with another
|
||||
// remote folder (a/A on a case-insensitive volume).
|
||||
guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue }
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes local directories under [rootRel] that no longer exist on the
|
||||
/// server ([remoteFolders], relative paths). Only *empty* directories go:
|
||||
/// by the time this runs the diff has already deleted the files that were
|
||||
/// inside a deleted server folder, while a directory that still holds
|
||||
/// something (a file this device created that hasn't synced) is left alone.
|
||||
/// Never removes the sync root itself.
|
||||
static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set<String>) {
|
||||
let fm = FileManager.default
|
||||
let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel)
|
||||
var isDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return }
|
||||
|
||||
func prune(_ dir: URL, rel: String) {
|
||||
let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
|
||||
for child in children {
|
||||
let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
if childIsDir {
|
||||
prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent)
|
||||
}
|
||||
}
|
||||
guard !rel.isEmpty, !remoteFolders.contains(rel),
|
||||
let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty
|
||||
else { return }
|
||||
// rmdir, not removeItem: it refuses a directory that isn't empty, so a
|
||||
// file that appeared since the check survives.
|
||||
rmdir(dir.path)
|
||||
}
|
||||
prune(top, rel: rootRel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times
|
||||
// are milliseconds since the epoch, like the Kotlin side, so the persisted
|
||||
// state means the same thing on both platforms.
|
||||
|
||||
/// One file or folder the server reported in a PROPFIND.
|
||||
struct SyncRemoteEntry: Equatable {
|
||||
/// Path relative to the user's files root, no trailing slash ("/Docs/a.txt").
|
||||
let path: String
|
||||
let fileId: String
|
||||
let etag: String
|
||||
let lastModified: Int64
|
||||
let size: Int64
|
||||
let isFolder: Bool
|
||||
}
|
||||
|
||||
/// What the last successful sync recorded for a file - the baseline a later
|
||||
/// pass diffs the server and the device against.
|
||||
struct SyncFileState: Codable, Equatable {
|
||||
let relPath: String
|
||||
let etag: String
|
||||
let lastModified: Int64
|
||||
let size: Int64
|
||||
let localMTime: Int64
|
||||
}
|
||||
|
||||
/// A configured sync root's etag at the end of its last clean, complete walk.
|
||||
/// Nextcloud propagates a change to any descendant up through every ancestor's
|
||||
/// etag, so an unchanged root etag means nothing beneath it changed.
|
||||
struct SyncRootMarker: Codable, Equatable {
|
||||
let etag: String
|
||||
let fullWalkAt: Int64
|
||||
}
|
||||
|
||||
enum SyncAction: Equatable {
|
||||
case download(SyncRemoteEntry)
|
||||
case upload(relPath: String, fileId: String)
|
||||
case delete(relPath: String, fileId: String)
|
||||
case conflict(SyncRemoteEntry, relPath: String)
|
||||
}
|
||||
|
||||
/// A file changed both on the device and on the server since the last sync.
|
||||
struct SyncConflict: Codable, Equatable {
|
||||
let accountId: String
|
||||
let fileId: String
|
||||
let remotePath: String
|
||||
let relPath: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// Everything a sync run (foreground or background) needs for one account.
|
||||
/// Persisted in the Keychain - it carries the auth header.
|
||||
struct SyncAccountConfig: Codable, Equatable {
|
||||
let accountId: String
|
||||
let serverUrl: String
|
||||
let username: String
|
||||
let authHeader: String
|
||||
/// Remote paths (files or folders) to mirror.
|
||||
var folders: [String]
|
||||
var wifiOnly: Bool
|
||||
/// Minutes between background syncs; nil = no background sync (manual and
|
||||
/// in-app refresh only).
|
||||
var intervalMinutes: Int?
|
||||
/// Whether automatic runs may post a summary notification.
|
||||
var notify: Bool
|
||||
}
|
||||
|
||||
struct SyncRunSummary: Equatable {
|
||||
var downloaded = 0
|
||||
var uploaded = 0
|
||||
var deleted = 0
|
||||
var conflicts: [SyncConflict] = []
|
||||
|
||||
var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 }
|
||||
}
|
||||
|
||||
/// The server couldn't be reached or answered with an error (anything but a
|
||||
/// clean 404). Distinct from "the path is genuinely gone": treating a network
|
||||
/// blip as an empty listing made every previously synced file look deleted
|
||||
/// server-side, and the diff then deleted the local copies. Callers skip the
|
||||
/// affected path for this run instead.
|
||||
struct SyncRemoteUnavailable: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
var description: String { message }
|
||||
}
|
||||
|
||||
/// The recorded sync state exists but couldn't be read (corrupt, or an I/O
|
||||
/// error). Treating that as "nothing recorded yet" would make every local
|
||||
/// file look untracked and overwrite local edits with the server's copy, so
|
||||
/// a run that hits it does nothing at all.
|
||||
struct SyncStateUnreadable: Error, CustomStringConvertible {
|
||||
let accountId: String
|
||||
var description: String { "Sync state for \(accountId) is unreadable" }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import Foundation
|
||||
|
||||
/// A mutex for `async` code: one holder at a time, waiters resume in order.
|
||||
/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held
|
||||
/// for the whole of any run that reads-then-rewrites a sync state map, which
|
||||
/// is also what keeps `SyncStatusBus` - it tracks one account at a time -
|
||||
/// coherent when several accounts are due.
|
||||
///
|
||||
/// Waiting is cancellable: a task that's cancelled while queued (a background
|
||||
/// run whose time budget expired, a superseded "Sync now") leaves the queue
|
||||
/// at once with `CancellationError` instead of sitting behind a long run.
|
||||
final class AsyncMutex {
|
||||
private let lock = NSLock()
|
||||
private var locked = false
|
||||
private var waiters: [(id: UUID, continuation: CheckedContinuation<Void, Error>)] = []
|
||||
|
||||
func withLock<T>(_ body: () async throws -> T) async throws -> T {
|
||||
try await acquire()
|
||||
defer { release() }
|
||||
return try await body()
|
||||
}
|
||||
|
||||
private func acquire() async throws {
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
if Task.isCancelled {
|
||||
lock.unlock()
|
||||
continuation.resume(throwing: CancellationError())
|
||||
} else if !locked {
|
||||
locked = true
|
||||
lock.unlock()
|
||||
continuation.resume()
|
||||
} else {
|
||||
waiters.append((id, continuation))
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
lock.lock()
|
||||
if let index = waiters.firstIndex(where: { $0.id == id }) {
|
||||
let waiter = waiters.remove(at: index)
|
||||
lock.unlock()
|
||||
waiter.continuation.resume(throwing: CancellationError())
|
||||
} else {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func release() {
|
||||
lock.lock()
|
||||
if waiters.isEmpty {
|
||||
locked = false
|
||||
lock.unlock()
|
||||
} else {
|
||||
let next = waiters.removeFirst()
|
||||
lock.unlock()
|
||||
next.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live sync status for the app: whether a run is going, which files are
|
||||
/// transferring, and unresolved conflicts. In-memory only - what actually
|
||||
/// answers "is this file synced" is the on-disk state; this carries just the
|
||||
/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that
|
||||
/// conflicts are kept per account (a file id is only unique within one).
|
||||
final class SyncStatusBus {
|
||||
struct Status: Equatable {
|
||||
var accountId: String?
|
||||
var syncing = false
|
||||
var syncingFileIds: Set<String> = []
|
||||
/// Every account's unresolved conflicts - readers filter by account.
|
||||
var conflicts: [SyncConflict] = []
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var current = Status()
|
||||
|
||||
/// Called (on whatever thread changed it) after every update.
|
||||
var onChange: ((Status) -> Void)?
|
||||
|
||||
func snapshot() -> Status {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return current
|
||||
}
|
||||
|
||||
func setSyncing(accountId: String, _ syncing: Bool) {
|
||||
update {
|
||||
$0.accountId = accountId
|
||||
$0.syncing = syncing
|
||||
if !syncing { $0.syncingFileIds = [] }
|
||||
}
|
||||
}
|
||||
|
||||
func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) {
|
||||
update {
|
||||
$0.accountId = accountId
|
||||
if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) }
|
||||
}
|
||||
}
|
||||
|
||||
func addConflicts(accountId: String, _ new: [SyncConflict]) {
|
||||
guard !new.isEmpty else { return }
|
||||
update {
|
||||
$0.accountId = accountId
|
||||
for conflict in new
|
||||
where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) {
|
||||
$0.conflicts.append(conflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeConflict(accountId: String, fileId: String) {
|
||||
update {
|
||||
$0.accountId = accountId
|
||||
$0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId }
|
||||
}
|
||||
}
|
||||
|
||||
private func update(_ mutate: (inout Status) -> Void) {
|
||||
lock.lock()
|
||||
mutate(¤t)
|
||||
let snapshot = current
|
||||
lock.unlock()
|
||||
onChange?(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// One account's sync pass: walk each configured path, diff it against the
|
||||
/// recorded state, transfer, and record the result. The iOS port of Android's
|
||||
/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same
|
||||
/// safeguards - and a few more, found in review:
|
||||
///
|
||||
/// - If the server can't be reached for a path - or answers with something
|
||||
/// that isn't a complete, plausible listing - that path is skipped for this
|
||||
/// run, never diffed against an empty or partial listing, which would look
|
||||
/// like files were deleted and wipe the local copies.
|
||||
/// - If the recorded state can't be read the run does nothing at all (it
|
||||
/// would otherwise treat every local file as untracked and overwrite local
|
||||
/// edits with the server's copies).
|
||||
/// - Paths the server sends are checked to stay inside the mirror, and two
|
||||
/// remote files that would land on the same local file are both skipped.
|
||||
/// - Server deletions remove the local copy, including local edits, like Android.
|
||||
/// - A root's etag is only remembered once its whole path synced cleanly, and
|
||||
/// only if the state it relies on was actually saved.
|
||||
struct SyncRunner {
|
||||
let client: DavSyncClient
|
||||
let store: SyncStore
|
||||
let bus: SyncStatusBus
|
||||
var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }
|
||||
|
||||
/// Even when a root's etag hasn't changed, walk it in full at least this
|
||||
/// often: etag propagation isn't reliable everywhere (external storage
|
||||
/// mounts in particular), so this bounds how stale a mirror can get.
|
||||
static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000
|
||||
|
||||
/// State is flushed to disk after this many transfers.
|
||||
static let saveEvery = 20
|
||||
|
||||
/// [force] is an explicit "Sync now" / newly added path: always a full
|
||||
/// walk, never trusting the root-etag shortcut.
|
||||
func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary {
|
||||
var summary = SyncRunSummary()
|
||||
guard !config.folders.isEmpty else { return summary }
|
||||
let accountId = config.accountId
|
||||
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||
let syncRoot = store.syncRoot(accountId: accountId)
|
||||
|
||||
var state: [String: SyncFileState]
|
||||
do {
|
||||
state = try store.loadState(accountId: accountId)
|
||||
} catch {
|
||||
NSLog("[Sync] %@ - skipping this run", String(describing: error))
|
||||
return summary
|
||||
}
|
||||
var markers = store.loadRootMarkers(accountId: accountId)
|
||||
var sinceSave = 0
|
||||
|
||||
bus.setSyncing(accountId: accountId, true)
|
||||
defer {
|
||||
// State first, then the markers that depend on it, then announce.
|
||||
if store.saveState(accountId: accountId, state) {
|
||||
markers = markers.filter { config.folders.contains($0.key) }
|
||||
store.saveRootMarkers(accountId: accountId, markers)
|
||||
} else {
|
||||
NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId)
|
||||
}
|
||||
bus.setSyncing(accountId: accountId, false)
|
||||
}
|
||||
|
||||
func transferred() {
|
||||
sinceSave += 1
|
||||
if sinceSave >= Self.saveEvery {
|
||||
if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") }
|
||||
sinceSave = 0
|
||||
}
|
||||
}
|
||||
|
||||
for path in config.folders {
|
||||
if Task.isCancelled { break }
|
||||
do {
|
||||
// A configured path can be a file or a folder - check which before
|
||||
// deciding whether to walk it or just diff the single item.
|
||||
let selfEntry = try await client.propfindSelf(creds, path: path)
|
||||
let priorIds = SyncDiff.priorFileIds(state: state, path: path)
|
||||
|
||||
// Cheap "did anything change?" check: an unchanged root etag means
|
||||
// nothing beneath it changed on the server, and if every local file
|
||||
// is also still as recorded there's nothing to upload or re-download
|
||||
// either - skip the walk (one Depth-0 PROPFIND instead of one per
|
||||
// subfolder).
|
||||
if !force, let root = selfEntry, root.isFolder, let marker = markers[path],
|
||||
marker.etag == root.etag,
|
||||
now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs,
|
||||
!priorIds.isEmpty,
|
||||
SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot)
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
// The server says this synced path no longer exists (a clean 404, not
|
||||
// a network error): flag it so the app drops it from its synced list.
|
||||
store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil)
|
||||
|
||||
var entries: [SyncRemoteEntry] = []
|
||||
if let root = selfEntry {
|
||||
if root.isFolder {
|
||||
entries = try await client.walk(creds, root: path)
|
||||
} else {
|
||||
entries = [root]
|
||||
}
|
||||
}
|
||||
var pathClean = true
|
||||
let collisions = SyncDiff.collidingRelPaths(entries)
|
||||
|
||||
let actions = SyncDiff.diffFolder(
|
||||
entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot)
|
||||
for action in actions {
|
||||
try Task.checkCancellation()
|
||||
switch action {
|
||||
case .download(let entry):
|
||||
let rel = LocalFS.relative(entry.path)
|
||||
guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else {
|
||||
NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel)
|
||||
pathClean = false
|
||||
continue
|
||||
}
|
||||
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true)
|
||||
let ok = try await client.download(creds, remotePath: entry.path, to: dest)
|
||||
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false)
|
||||
if ok {
|
||||
let local = LocalFS.stat(dest)
|
||||
state[entry.fileId] = SyncFileState(
|
||||
relPath: rel, etag: entry.etag, lastModified: entry.lastModified,
|
||||
size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0)
|
||||
summary.downloaded += 1
|
||||
transferred()
|
||||
} else {
|
||||
pathClean = false
|
||||
}
|
||||
|
||||
case .upload(let rel, let fileId):
|
||||
guard let local = LocalFS.resolve(rel, in: syncRoot) else {
|
||||
pathClean = false
|
||||
continue
|
||||
}
|
||||
bus.markFileSyncing(accountId: accountId, fileId: fileId, true)
|
||||
var ok = false
|
||||
if FileManager.default.fileExists(atPath: local.path) {
|
||||
ok = try await client.upload(creds, remotePath: "/" + rel, from: local)
|
||||
}
|
||||
bus.markFileSyncing(accountId: accountId, fileId: fileId, false)
|
||||
if ok, let stat = LocalFS.stat(local) {
|
||||
// Our own PUT changed the file's etag; record the new one, or
|
||||
// the next pass would see "server changed" and re-download it.
|
||||
// If it can't be read back, keep the old one and let the root
|
||||
// be walked for real next time.
|
||||
let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil
|
||||
if fresh == nil { pathClean = false }
|
||||
state[fileId] = SyncFileState(
|
||||
relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "",
|
||||
lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs)
|
||||
summary.uploaded += 1
|
||||
transferred()
|
||||
} else {
|
||||
pathClean = false
|
||||
}
|
||||
|
||||
case .delete(let rel, let fileId):
|
||||
// Only counts as a removal if a local copy existed and went;
|
||||
// dropping stale state for a file never on disk isn't news.
|
||||
if let local = LocalFS.resolve(rel, in: syncRoot),
|
||||
FileManager.default.fileExists(atPath: local.path),
|
||||
(try? FileManager.default.removeItem(at: local)) != nil
|
||||
{
|
||||
summary.deleted += 1
|
||||
}
|
||||
state[fileId] = nil
|
||||
|
||||
case .conflict(let entry, let rel):
|
||||
summary.conflicts.append(
|
||||
SyncConflict(
|
||||
accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel,
|
||||
name: (rel as NSString).lastPathComponent))
|
||||
pathClean = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the folder structure itself: empty folders created on the
|
||||
// server appear on the device, and folders deleted on the server
|
||||
// (whose files the diff has already removed) don't linger.
|
||||
if selfEntry == nil || selfEntry?.isFolder == true {
|
||||
let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if selfEntry != nil {
|
||||
SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions)
|
||||
}
|
||||
var remoteFolders = Set(
|
||||
entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) })
|
||||
if selfEntry != nil { remoteFolders.insert(rootRel) }
|
||||
SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders)
|
||||
}
|
||||
|
||||
if let root = selfEntry, root.isFolder, pathClean {
|
||||
markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now())
|
||||
} else {
|
||||
markers[path] = nil
|
||||
}
|
||||
} catch is CancellationError {
|
||||
markers[path] = nil // cut short: make the next run walk it for real
|
||||
break
|
||||
} catch {
|
||||
// Couldn't reach the server for this path (or it answered with
|
||||
// nothing usable): leave its state and marker alone and try again
|
||||
// next run.
|
||||
NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
if !summary.conflicts.isEmpty {
|
||||
bus.addConflicts(accountId: accountId, summary.conflicts)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/// Applies the user's choice for one conflicted file - `local` uploads the
|
||||
/// device's copy over the server's, `server` downloads the server's over
|
||||
/// the device's - then refreshes the recorded etag so the next pass doesn't
|
||||
/// immediately re-flag it. Returns whether it worked.
|
||||
func resolveConflict(
|
||||
_ conflict: SyncConflict, resolution: String, creds: SyncCredentials
|
||||
) async -> Bool {
|
||||
// An unreadable baseline must not be overwritten with a one-file one.
|
||||
guard var state = try? store.loadState(accountId: conflict.accountId),
|
||||
let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId))
|
||||
else { return false }
|
||||
|
||||
var ok = false
|
||||
do {
|
||||
switch resolution {
|
||||
case "local":
|
||||
if FileManager.default.fileExists(atPath: local.path) {
|
||||
ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local)
|
||||
}
|
||||
case "server":
|
||||
ok = try await client.download(creds, remotePath: conflict.remotePath, to: local)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
guard ok else { return false }
|
||||
|
||||
let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil
|
||||
let stat = LocalFS.stat(local)
|
||||
state[conflict.fileId] = SyncFileState(
|
||||
relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0,
|
||||
size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0)
|
||||
store.saveState(accountId: conflict.accountId, state)
|
||||
bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// The on-device side of sync: the mirrored files and the per-account sync
|
||||
/// state. Laid out under one base directory (Application Support, which is
|
||||
/// also where Dart's `SyncService.baseDirectory()` looks for the mirror):
|
||||
///
|
||||
/// <base>/sync/<accountId>/... the mirrored files
|
||||
/// <base>/sync-state/<accountId>/*.json state, root markers, missing roots
|
||||
///
|
||||
/// Plain JSON files rather than `UserDefaults`: the state map grows with the
|
||||
/// number of synced files. Not thread-safe by itself - the sync runner holds
|
||||
/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`).
|
||||
final class SyncStore {
|
||||
/// Bumped whenever a walk starts doing something new (e.g. mirroring empty
|
||||
/// folders) so markers recorded by an older version - which would make the
|
||||
/// next run skip the walk - are ignored once. Same value as Android.
|
||||
static let rootsVersion = 2
|
||||
|
||||
static let shared = SyncStore(base: defaultBase)
|
||||
|
||||
static var defaultBase: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
let base: URL
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
init(base: URL) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
// MARK: - Locations
|
||||
|
||||
/// The account's mirror folder, created on demand. Marked excluded from
|
||||
/// iCloud/device backups: it's a re-downloadable copy of server data.
|
||||
func syncRoot(accountId: String) -> URL {
|
||||
let top = base.appendingPathComponent("sync", isDirectory: true)
|
||||
let root = top.appendingPathComponent(accountId, isDirectory: true)
|
||||
try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
var values = URLResourceValues()
|
||||
values.isExcludedFromBackup = true
|
||||
var topMutable = top
|
||||
try? topMutable.setResourceValues(values)
|
||||
return root
|
||||
}
|
||||
|
||||
private func stateDirectory(accountId: String) -> URL {
|
||||
let dir = base.appendingPathComponent("sync-state", isDirectory: true)
|
||||
.appendingPathComponent(accountId, isDirectory: true)
|
||||
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func file(_ accountId: String, _ name: String) -> URL {
|
||||
stateDirectory(accountId: accountId).appendingPathComponent(name)
|
||||
}
|
||||
|
||||
// MARK: - Per-file state
|
||||
|
||||
/// The recorded baseline - empty if nothing has been recorded yet, and
|
||||
/// *throws* if there is a file that can't be read or decoded (see
|
||||
/// `SyncStateUnreadable`).
|
||||
func loadState(accountId: String) throws -> [String: SyncFileState] {
|
||||
let url = file(accountId, "state.json")
|
||||
guard fileManager.fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url))
|
||||
} catch {
|
||||
throw SyncStateUnreadable(accountId: accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/// False if the state couldn't be written - callers must not then record
|
||||
/// anything that assumes it was.
|
||||
@discardableResult
|
||||
func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool {
|
||||
write(state, to: file(accountId, "state.json"))
|
||||
}
|
||||
|
||||
// MARK: - Root markers
|
||||
|
||||
private struct MarkersFile: Codable {
|
||||
let version: Int
|
||||
let markers: [String: SyncRootMarker]
|
||||
}
|
||||
|
||||
func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] {
|
||||
guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")),
|
||||
stored.version == Self.rootsVersion
|
||||
else { return [:] }
|
||||
return stored.markers
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool {
|
||||
write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json"))
|
||||
}
|
||||
|
||||
// MARK: - Roots the server says are gone
|
||||
|
||||
func loadMissingRoots(accountId: String) -> Set<String> {
|
||||
Set(read([String].self, from: file(accountId, "missing.json")) ?? [])
|
||||
}
|
||||
|
||||
func setRootMissing(accountId: String, path: String, missing: Bool) {
|
||||
var current = loadMissingRoots(accountId: accountId)
|
||||
let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil)
|
||||
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
|
||||
}
|
||||
|
||||
// MARK: - Removing a path's mirror
|
||||
|
||||
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
|
||||
/// forgets its entries - 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 not pull it back.
|
||||
func removeLocalSync(accountId: String, path: String) {
|
||||
var cleanPath = path.trimmingCharacters(in: .whitespaces)
|
||||
if cleanPath.hasPrefix("/") { cleanPath.removeFirst() }
|
||||
while cleanPath.hasSuffix("/") { cleanPath.removeLast() }
|
||||
let prefix = cleanPath.isEmpty ? "" : cleanPath + "/"
|
||||
|
||||
// An unreadable baseline is left alone rather than overwritten with a
|
||||
// partial one; the next run reports it and does nothing.
|
||||
if var state = try? loadState(accountId: accountId) {
|
||||
// The sync root itself ("/") covers every recorded file.
|
||||
for (fileId, entry) in state
|
||||
where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) {
|
||||
state[fileId] = nil
|
||||
}
|
||||
saveState(accountId: accountId, state)
|
||||
}
|
||||
|
||||
setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false)
|
||||
setRootMissing(accountId: accountId, path: cleanPath, missing: false)
|
||||
|
||||
// A root marker for this path (or an ancestor/descendant that also covers
|
||||
// some of what was just deleted) would make the next background pass
|
||||
// think nothing changed and skip re-downloading.
|
||||
let removedPath = "/" + cleanPath
|
||||
var markers = loadRootMarkers(accountId: accountId)
|
||||
let stale = markers.keys.filter { key in
|
||||
var k = key
|
||||
while k.hasSuffix("/") { k.removeLast() }
|
||||
return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/")
|
||||
|| removedPath.hasPrefix(k + "/")
|
||||
}
|
||||
if !stale.isEmpty {
|
||||
stale.forEach { markers[$0] = nil }
|
||||
saveRootMarkers(accountId: accountId, markers)
|
||||
}
|
||||
|
||||
let root = syncRoot(accountId: accountId)
|
||||
let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath)
|
||||
if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) }
|
||||
if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
||||
guard let data = try? JSONEncoder().encode(value) else { return false }
|
||||
do {
|
||||
try data.write(to: url, options: .atomic)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct UploadFile {
|
||||
let uri: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// Uploads and downloads on a background `URLSession`, so a transfer keeps
|
||||
/// going - and finishes - after the app is suspended or closed. The iOS
|
||||
/// counterpart of Android's `ShareUploadService`/`DownloadService`
|
||||
/// foreground services, behind the same Dart channels (`NativeServices`).
|
||||
///
|
||||
/// Uploads are one `PUT` per file straight to the account's WebDAV root
|
||||
/// (no chunking), from a staged copy in Caches - a background upload task
|
||||
/// needs a file that stays put, and the picker's temp file may not. Downloads
|
||||
/// land in `Documents/Downloads`, which is visible in the Files app
|
||||
/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`).
|
||||
final class TransferManager: NSObject {
|
||||
static let shared = TransferManager()
|
||||
static let sessionIdentifier = "dev.ayushya.noo.transfers"
|
||||
|
||||
/// Called on the main thread when an upload batch ends with at least one
|
||||
/// file uploaded: (remote folder, succeeded, failed).
|
||||
var onUploadBatchFinished: ((String, Int, Int) -> Void)?
|
||||
|
||||
/// The system's completion handler from
|
||||
/// `application(_:handleEventsForBackgroundURLSession:)`, called once the
|
||||
/// session has delivered every pending event.
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
|
||||
config.sessionSendsLaunchEvents = true
|
||||
config.isDiscretionary = false
|
||||
config.waitsForConnectivity = true
|
||||
// Two accounts can share a server: authenticate by the request's own
|
||||
// header only, never a cookie another account's request left behind.
|
||||
config.httpShouldSetCookies = false
|
||||
config.httpCookieAcceptPolicy = .never
|
||||
config.urlCredentialStorage = nil
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
/// The session the Share Extension's uploads run on (see `ShareUpload`).
|
||||
/// The extension is gone by the time they finish, so the app recreates it -
|
||||
/// same identifier and shared container - to receive the results.
|
||||
private lazy var shareSession = URLSession(
|
||||
configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil)
|
||||
|
||||
/// Touch both sessions at launch so a system relaunch for finished
|
||||
/// background transfers reconnects to them.
|
||||
func reconnect() {
|
||||
_ = session
|
||||
_ = shareSession
|
||||
}
|
||||
|
||||
// MARK: - Starting transfers
|
||||
|
||||
func startUpload(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
files: [UploadFile],
|
||||
remoteFolder: String
|
||||
) throws {
|
||||
guard !files.isEmpty else { throw TransferError(message: "No files to upload.") }
|
||||
let batch = UUID().uuidString
|
||||
let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true)
|
||||
|
||||
var tasks: [URLSessionUploadTask] = []
|
||||
for (index, file) in files.enumerated() {
|
||||
guard let source = URL(string: file.uri), source.isFileURL else {
|
||||
throw TransferError(message: "Can't read \(file.name).")
|
||||
}
|
||||
let staged = stageDir.appendingPathComponent("\(index)-\(file.name)")
|
||||
do {
|
||||
try FileManager.default.copyItem(at: source, to: staged)
|
||||
// A file the Share Extension left in the App Group is ours to clean
|
||||
// up once it's staged; a picker's temp file isn't.
|
||||
if source.path.contains("/\(SharedInbox.folderName)/") {
|
||||
try? FileManager.default.removeItem(at: source)
|
||||
}
|
||||
} catch {
|
||||
throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)")
|
||||
}
|
||||
guard let url = WebDAV.fileURL(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
remotePath: WebDAV.join(remoteFolder, file.name)
|
||||
) else {
|
||||
throw TransferError(message: "Invalid server address.")
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date {
|
||||
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||
}
|
||||
let task = session.uploadTask(with: request, fromFile: staged)
|
||||
task.taskDescription = Self.encode(
|
||||
TransferTaskInfo(
|
||||
kind: .upload, batch: batch, name: file.name,
|
||||
remoteFolder: remoteFolder, stagedPath: staged.path
|
||||
)
|
||||
)
|
||||
tasks.append(task)
|
||||
}
|
||||
TransferBatchStore.save(
|
||||
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||
id: batch
|
||||
)
|
||||
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||
tasks.forEach { $0.resume() }
|
||||
}
|
||||
|
||||
func startDownload(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
items: [(path: String, name: String)]
|
||||
) throws {
|
||||
guard !items.isEmpty else { throw TransferError(message: "No files to download.") }
|
||||
let batch = UUID().uuidString
|
||||
var tasks: [URLSessionDownloadTask] = []
|
||||
for item in items {
|
||||
guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else {
|
||||
throw TransferError(message: "Invalid server address.")
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
let task = session.downloadTask(with: request)
|
||||
task.taskDescription = Self.encode(
|
||||
TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil)
|
||||
)
|
||||
tasks.append(task)
|
||||
}
|
||||
TransferBatchStore.save(
|
||||
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
|
||||
id: batch
|
||||
)
|
||||
TransferNotifications.started(kind: .download, batch: batch, count: tasks.count)
|
||||
tasks.forEach { $0.resume() }
|
||||
}
|
||||
|
||||
// MARK: - Locations
|
||||
|
||||
private static func stagingDirectory() throws -> URL {
|
||||
let caches = try FileManager.default.url(
|
||||
for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
||||
)
|
||||
return caches.appendingPathComponent("uploads", isDirectory: true)
|
||||
}
|
||||
|
||||
static func downloadsDirectory() throws -> URL {
|
||||
let documents = try FileManager.default.url(
|
||||
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
||||
)
|
||||
let dir = documents.appendingPathComponent("Downloads", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
// MARK: - Batch bookkeeping
|
||||
|
||||
private static func encode(_ info: TransferTaskInfo) -> String? {
|
||||
(try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) }
|
||||
}
|
||||
|
||||
private static func info(for task: URLSessionTask) -> TransferTaskInfo? {
|
||||
task.taskDescription
|
||||
.flatMap { $0.data(using: .utf8) }
|
||||
.flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }
|
||||
}
|
||||
|
||||
/// Counts one file's outcome; fires the batch summary when it was the last.
|
||||
private func record(_ info: TransferTaskInfo, success: Bool) {
|
||||
if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) }
|
||||
}
|
||||
|
||||
private func finish(_ batch: TransferBatch, id: String) {
|
||||
TransferNotifications.finished(batch, id: id)
|
||||
reportToDart(batch)
|
||||
}
|
||||
|
||||
/// Tells Dart (so `FilesController` can refresh the folder) once an upload
|
||||
/// batch has landed at least one file.
|
||||
private func reportToDart(_ batch: TransferBatch) {
|
||||
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders the Share Extension finished uploading into while the app wasn't
|
||||
/// running - call when the app becomes active.
|
||||
func deliverExtensionUploads() {
|
||||
for folder in TransferBatchStore.consumeFinishedUploadFolders() {
|
||||
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSession delegates
|
||||
|
||||
extension TransferManager: URLSessionDownloadDelegate {
|
||||
func urlSession(
|
||||
_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
|
||||
) {
|
||||
guard let info = Self.info(for: downloadTask) else { return }
|
||||
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(status) else {
|
||||
record(info, success: false)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name)
|
||||
try FileManager.default.moveItem(at: location, to: destination)
|
||||
record(info, success: true)
|
||||
} catch {
|
||||
record(info, success: false)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let info = Self.info(for: task) else { return }
|
||||
switch info.kind {
|
||||
case .upload:
|
||||
if let result = UploadResults.handle(task, error: error), let batch = result.batch {
|
||||
reportToDart(batch)
|
||||
}
|
||||
case .download:
|
||||
// A finished download was already counted in didFinishDownloadingTo;
|
||||
// only a transport failure arrives here.
|
||||
if error != nil { record(info, success: false) }
|
||||
}
|
||||
}
|
||||
|
||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundCompletionHandler?()
|
||||
self.backgroundCompletionHandler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.ayushya.noo</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,11 +2,338 @@ import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import Runner
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
func testFileURLEncodesEachSegment() {
|
||||
let url = WebDAV.fileURL(
|
||||
serverUrl: "https://cloud.example.com", username: "alice",
|
||||
remotePath: "/Tax Forms/2026 #1/a&b.pdf")
|
||||
XCTAssertEqual(
|
||||
url?.absoluteString,
|
||||
"https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf")
|
||||
}
|
||||
|
||||
func testFileURLKeepsServerSubPathAndDropsTrailingSlash() {
|
||||
let url = WebDAV.fileURL(
|
||||
serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt")
|
||||
XCTAssertEqual(
|
||||
url?.absoluteString,
|
||||
"https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt")
|
||||
}
|
||||
|
||||
func testFileURLForRoot() {
|
||||
XCTAssertEqual(
|
||||
WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString,
|
||||
"https://c.io/remote.php/dav/files/u")
|
||||
}
|
||||
|
||||
func testJoinUsesOneSlash() {
|
||||
XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt")
|
||||
XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt")
|
||||
XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt")
|
||||
}
|
||||
|
||||
func testUniqueURLNumbersCollisions() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt")
|
||||
FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil)
|
||||
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt")
|
||||
FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil)
|
||||
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt")
|
||||
FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil)
|
||||
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)")
|
||||
}
|
||||
|
||||
// MARK: - SharedInbox (Share Extension hand-off)
|
||||
|
||||
private func makeRoot() throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||
return root
|
||||
}
|
||||
|
||||
private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem {
|
||||
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||
let file = batch.appendingPathComponent(name)
|
||||
try contents.write(to: file, atomically: true, encoding: .utf8)
|
||||
return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count))
|
||||
}
|
||||
|
||||
func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws {
|
||||
let root = try makeRoot()
|
||||
let a = try makeSharedFile(in: root, name: "a.txt")
|
||||
let b = try makeSharedFile(in: root, name: "b.txt")
|
||||
try SharedInbox.append([a], in: root)
|
||||
try SharedInbox.append([b], in: root)
|
||||
|
||||
XCTAssertEqual(SharedInbox.consume(in: root), [a, b])
|
||||
XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once")
|
||||
}
|
||||
|
||||
func testInboxConsumeDropsFilesThatNoLongerExist() throws {
|
||||
let root = try makeRoot()
|
||||
let kept = try makeSharedFile(in: root, name: "kept.txt")
|
||||
let gone = try makeSharedFile(in: root, name: "gone.txt")
|
||||
try SharedInbox.append([kept, gone], in: root)
|
||||
try FileManager.default.removeItem(atPath: gone.path)
|
||||
|
||||
XCTAssertEqual(SharedInbox.consume(in: root), [kept])
|
||||
}
|
||||
|
||||
func testInboxConsumeWithNothingPendingIsEmpty() throws {
|
||||
XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), [])
|
||||
}
|
||||
|
||||
func testInboxAppendOfNothingCreatesNoManifest() throws {
|
||||
let root = try makeRoot()
|
||||
try SharedInbox.append([], in: root)
|
||||
XCTAssertEqual(SharedInbox.consume(in: root), [])
|
||||
}
|
||||
|
||||
// MARK: - DavFolderParser (Share Extension folder picker)
|
||||
|
||||
private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data {
|
||||
let body = responses.map { r in
|
||||
"""
|
||||
<d:response><d:href>\(r.href)</d:href><d:propstat><d:prop>\
|
||||
<d:resourcetype>\(r.collection ? "<d:collection/>" : "")</d:resourcetype>\
|
||||
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
"""
|
||||
}.joined()
|
||||
return Data(
|
||||
"<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">\(body)</d:multistatus>".utf8)
|
||||
}
|
||||
|
||||
func testFoldersListsOnlyChildFoldersSortedWithoutHidden() {
|
||||
let xml = multistatus([
|
||||
("/remote.php/dav/files/alice/Docs/", true),
|
||||
("/remote.php/dav/files/alice/Docs/zeta/", true),
|
||||
("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true),
|
||||
("/remote.php/dav/files/alice/Docs/note.txt", false),
|
||||
("/remote.php/dav/files/alice/Docs/.git/", true),
|
||||
])
|
||||
XCTAssertEqual(
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs"),
|
||||
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
|
||||
XCTAssertEqual(
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
|
||||
[".git", "Tax Forms", "zeta"])
|
||||
XCTAssertEqual(
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
|
||||
[".git"])
|
||||
}
|
||||
|
||||
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
|
||||
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
|
||||
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
|
||||
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
|
||||
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
|
||||
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
|
||||
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
|
||||
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
|
||||
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
|
||||
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
|
||||
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
|
||||
}
|
||||
|
||||
func testFoldersAtRootAndUnderServerSubPath() {
|
||||
let xml = multistatus([
|
||||
("/nextcloud/remote.php/dav/files/bob/", true),
|
||||
("/nextcloud/remote.php/dav/files/bob/Photos/", true),
|
||||
])
|
||||
XCTAssertEqual(
|
||||
DavFolderParser.folders(from: xml, excluding: "/"),
|
||||
[DavFolder(name: "Photos", path: "/Photos")])
|
||||
}
|
||||
|
||||
func testRelativePath() {
|
||||
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D")
|
||||
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/")
|
||||
XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else"))
|
||||
}
|
||||
|
||||
func testFoldersIgnoresGarbage() {
|
||||
XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), [])
|
||||
}
|
||||
|
||||
// MARK: - Shared account (Keychain group shared with the extension)
|
||||
|
||||
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
|
||||
SharedAccount(
|
||||
id: id, serverUrl: "https://\(id).example.com", username: id,
|
||||
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
|
||||
}
|
||||
|
||||
func testSharedAccountsRoundTripThroughTheKeychain() throws {
|
||||
let accounts = SharedAccounts(
|
||||
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
|
||||
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
|
||||
addTeardownBlock { SharedAccountStore.clear() }
|
||||
|
||||
SharedAccountStore.clear()
|
||||
XCTAssertNil(SharedAccountStore.load())
|
||||
try SharedAccountStore.save(accounts)
|
||||
XCTAssertEqual(SharedAccountStore.load(), accounts)
|
||||
|
||||
let replacement = SharedAccounts(
|
||||
accounts: [account("carol")], activeId: "carol",
|
||||
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||
try SharedAccountStore.save(replacement)
|
||||
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
||||
|
||||
SharedAccountStore.clear()
|
||||
XCTAssertNil(SharedAccountStore.load())
|
||||
}
|
||||
|
||||
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
|
||||
var shared = SharedAccounts(
|
||||
accounts: [account("alice"), account("bob")], activeId: "bob",
|
||||
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||
XCTAssertEqual(shared.active?.id, "bob")
|
||||
shared.activeId = "gone"
|
||||
XCTAssertEqual(shared.active?.id, "alice")
|
||||
shared.activeId = nil
|
||||
XCTAssertEqual(shared.active?.id, "alice")
|
||||
}
|
||||
|
||||
func testUnlockRulesAreIndependentOfLoginLock() {
|
||||
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
||||
let shared = SharedAccounts(
|
||||
accounts: [account("a")], activeId: "a",
|
||||
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
||||
}
|
||||
XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false))
|
||||
XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true))
|
||||
XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false))
|
||||
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true))
|
||||
}
|
||||
|
||||
// Which actions in the share sheet ask to unlock - and how many prompts.
|
||||
|
||||
private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts {
|
||||
SharedAccounts(
|
||||
accounts: [account("alice"), account("bob")], activeId: "alice",
|
||||
loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||
}
|
||||
|
||||
func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() {
|
||||
let shared = lockedAccounts(switching: true, hidden: true)
|
||||
let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide)
|
||||
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false))
|
||||
XCTAssertFalse(needs.needsPrompt)
|
||||
}
|
||||
|
||||
func testChoosingAnotherAccountPromptsEveryTime() {
|
||||
let shared = lockedAccounts(switching: true, hidden: false)
|
||||
// No unlock is remembered: the same choice asks again on every call.
|
||||
for _ in 0..<3 {
|
||||
XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
|
||||
}
|
||||
XCTAssertFalse(
|
||||
lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
|
||||
}
|
||||
|
||||
func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() {
|
||||
let needs = lockedAccounts(switching: true, hidden: true)
|
||||
.unlockNeeds(choosing: account("bob"), showing: .include)
|
||||
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true))
|
||||
XCTAssertTrue(needs.needsPrompt)
|
||||
}
|
||||
|
||||
func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() {
|
||||
let needs = lockedAccounts(switching: true, hidden: true)
|
||||
.unlockNeeds(choosing: account("alice"), showing: .only)
|
||||
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true))
|
||||
}
|
||||
|
||||
func testOnlyTurningHiddenFoldersOnAsksToUnlock() {
|
||||
let locked = lockedAccounts(switching: false, hidden: true)
|
||||
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only))
|
||||
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include))
|
||||
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks")
|
||||
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks")
|
||||
XCTAssertFalse(
|
||||
lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include),
|
||||
"no lock set, no prompt")
|
||||
}
|
||||
|
||||
// MARK: - TransferBatchStore
|
||||
|
||||
func testBatchSummaryFiresOnlyOnTheLastFile() {
|
||||
let batchId = UUID().uuidString
|
||||
TransferBatchStore.save(
|
||||
TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId)
|
||||
let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil)
|
||||
|
||||
XCTAssertNil(TransferBatchStore.record(info, success: true))
|
||||
XCTAssertNil(TransferBatchStore.record(info, success: false))
|
||||
let finished = TransferBatchStore.record(info, success: true)
|
||||
XCTAssertEqual(finished?.succeeded, 2)
|
||||
XCTAssertEqual(finished?.failed, 1)
|
||||
XCTAssertEqual(finished?.remoteFolder, "/Docs")
|
||||
}
|
||||
|
||||
func testFinishedUploadFoldersAreNotedOnceAndConsumed() {
|
||||
_ = TransferBatchStore.consumeFinishedUploadFolders() // start clean
|
||||
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
||||
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
||||
TransferBatchStore.noteFinishedUpload(folder: "/Photos")
|
||||
|
||||
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"])
|
||||
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), [])
|
||||
}
|
||||
|
||||
func testNotificationFileCountWording() {
|
||||
XCTAssertEqual(TransferNotifications.files(1), "1 file")
|
||||
XCTAssertEqual(TransferNotifications.files(3), "3 files")
|
||||
}
|
||||
|
||||
// MARK: - External storage + tolerant decoding
|
||||
|
||||
func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() {
|
||||
let xml = Data(
|
||||
"""
|
||||
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:response><d:href>/remote.php/dav/files/a/</d:href><d:propstat><d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||
<d:response><d:href>/remote.php/dav/files/a/NAS/</d:href><d:propstat><d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>external</nc:mount-type>
|
||||
</d:prop></d:propstat></d:response>
|
||||
<d:response><d:href>/remote.php/dav/files/a/Docs/</d:href><d:propstat><d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||
<d:response><d:href>/remote.php/dav/files/a/Shared/</d:href><d:propstat><d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>shared</nc:mount-type>
|
||||
</d:prop></d:propstat></d:response>
|
||||
</d:multistatus>
|
||||
""".utf8)
|
||||
let folders = DavFolderParser.folders(from: xml, excluding: "/")
|
||||
XCTAssertEqual(folders.map(\.name), ["Docs", "NAS", "Shared"])
|
||||
XCTAssertEqual(folders.map(\.isExternal), [false, true, false], "only mount-type=external counts")
|
||||
|
||||
XCTAssertTrue(StorageFilter.cloud.shows(isExternal: false))
|
||||
XCTAssertFalse(StorageFilter.cloud.shows(isExternal: true))
|
||||
XCTAssertTrue(StorageFilter.external.shows(isExternal: true))
|
||||
XCTAssertFalse(StorageFilter.external.shows(isExternal: false))
|
||||
XCTAssertTrue(StorageFilter.all.shows(isExternal: true) && StorageFilter.all.shows(isExternal: false))
|
||||
XCTAssertEqual(StorageFilter(raw: "bogus"), .cloud)
|
||||
}
|
||||
|
||||
func testSharedAccountDecodesDataWrittenBeforeNewFieldsExisted() throws {
|
||||
let old = Data(
|
||||
"""
|
||||
{"id":"a","serverUrl":"https://x","username":"u","authHeader":"Basic x","displayName":"u@x"}
|
||||
""".utf8)
|
||||
let decoded = try JSONDecoder().decode(SharedAccount.self, from: old)
|
||||
XCTAssertEqual(decoded.hiddenFilter, "hide")
|
||||
XCTAssertEqual(decoded.storageScope, "cloud")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
import XCTest
|
||||
|
||||
@testable import Runner
|
||||
|
||||
/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1,
|
||||
/// GET, PUT) for the sync engine to run end to end against - with ancestor
|
||||
/// etags bumping on every change, like the real thing.
|
||||
final class FakeDav: URLProtocol {
|
||||
struct Node {
|
||||
var isFolder: Bool
|
||||
var data = Data()
|
||||
var etag: String
|
||||
var fileId: String
|
||||
var mtime = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
}
|
||||
|
||||
static var nodes: [String: Node] = [:]
|
||||
static var failAll = false
|
||||
/// A folder whose PROPFIND answers 500 while everything else works - for
|
||||
/// proving a half-built manifest is never trusted.
|
||||
static var failPropfindPath: String?
|
||||
/// Answer PROPFIND with a 207 whose XML is cut off mid-way.
|
||||
static var truncatePropfind = false
|
||||
static var log: [String] = []
|
||||
private static var counter = 0
|
||||
static let user = "alice"
|
||||
private static let base = "/remote.php/dav/files/alice"
|
||||
|
||||
static func reset() {
|
||||
nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")]
|
||||
failAll = false
|
||||
failPropfindPath = nil
|
||||
truncatePropfind = false
|
||||
log = []
|
||||
counter = 0
|
||||
}
|
||||
|
||||
private static func nextTag() -> String {
|
||||
counter += 1
|
||||
return "tag\(counter)"
|
||||
}
|
||||
|
||||
private static func parent(of path: String) -> String {
|
||||
let p = (path as NSString).deletingLastPathComponent
|
||||
return p.isEmpty ? "/" : p
|
||||
}
|
||||
|
||||
/// Bumps [path]'s etag and every ancestor's.
|
||||
private static func touch(_ path: String) {
|
||||
var p = path
|
||||
while true {
|
||||
nodes[p]?.etag = nextTag()
|
||||
if p == "/" { break }
|
||||
p = parent(of: p)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Test-side helpers
|
||||
|
||||
static func mkdir(_ path: String) {
|
||||
var parts: [String] = []
|
||||
for comp in path.split(separator: "/") {
|
||||
parts.append(String(comp))
|
||||
let p = "/" + parts.joined(separator: "/")
|
||||
if nodes[p] == nil {
|
||||
nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)")
|
||||
touch(parent(of: p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func put(_ path: String, _ text: String) {
|
||||
mkdir(parent(of: path))
|
||||
var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())")
|
||||
node.data = Data(text.utf8)
|
||||
nodes[path] = node
|
||||
touch(path)
|
||||
}
|
||||
|
||||
static func remove(_ path: String) {
|
||||
for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil }
|
||||
touch(parent(of: path))
|
||||
}
|
||||
|
||||
static func text(_ path: String) -> String? {
|
||||
nodes[path].flatMap { String(data: $0.data, encoding: .utf8) }
|
||||
}
|
||||
|
||||
// MARK: URLProtocol
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func stopLoading() {}
|
||||
|
||||
override func startLoading() {
|
||||
let request = self.request
|
||||
let method = request.httpMethod ?? "GET"
|
||||
let depth = request.value(forHTTPHeaderField: "Depth") ?? "-"
|
||||
var path = (request.url?.path ?? "").removingPercentEncoding ?? ""
|
||||
if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) }
|
||||
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
|
||||
if path.isEmpty { path = "/" }
|
||||
Self.log.append("\(method) \(depth) \(path)")
|
||||
|
||||
func send(_ status: Int, _ body: Data = Data()) {
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: body)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
if Self.failAll { return send(503) }
|
||||
|
||||
switch method {
|
||||
case "PROPFIND":
|
||||
if path == Self.failPropfindPath { return send(500) }
|
||||
guard Self.nodes[path] != nil else { return send(404) }
|
||||
var paths = [path]
|
||||
if depth == "1" {
|
||||
paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted()
|
||||
}
|
||||
var body = Self.multistatus(paths)
|
||||
// Only folder listings: a cut-off *listing* still holds a few complete
|
||||
// responses, which is the dangerous case (a partial manifest).
|
||||
if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) }
|
||||
send(207, Data(body.utf8))
|
||||
case "GET":
|
||||
guard let node = Self.nodes[path], !node.isFolder else { return send(404) }
|
||||
send(200, node.data)
|
||||
case "PUT":
|
||||
var data = Data()
|
||||
if let stream = request.httpBodyStream {
|
||||
stream.open()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while stream.hasBytesAvailable {
|
||||
let n = stream.read(&buffer, maxLength: buffer.count)
|
||||
if n <= 0 { break }
|
||||
data.append(buffer, count: n)
|
||||
}
|
||||
stream.close()
|
||||
} else if let body = request.httpBody {
|
||||
data = body
|
||||
}
|
||||
Self.mkdir(Self.parent(of: path))
|
||||
var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())")
|
||||
node.data = data
|
||||
Self.nodes[path] = node
|
||||
Self.touch(path)
|
||||
send(201)
|
||||
default:
|
||||
send(405)
|
||||
}
|
||||
}
|
||||
|
||||
private static let httpDate: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
|
||||
return f
|
||||
}()
|
||||
|
||||
private static func multistatus(_ paths: [String]) -> String {
|
||||
let responses = paths.map { p -> String in
|
||||
let node = nodes[p]!
|
||||
let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/")
|
||||
let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "")
|
||||
return """
|
||||
<d:response><d:href>\(href)</d:href><d:propstat><d:prop>\
|
||||
<d:getlastmodified>\(httpDate.string(from: node.mtime))</d:getlastmodified>\
|
||||
<d:getcontentlength>\(node.data.count)</d:getcontentlength>\
|
||||
<d:resourcetype>\(node.isFolder ? "<d:collection/>" : "")</d:resourcetype>\
|
||||
<d:getetag>"\(node.etag)"</d:getetag><oc:fileid>\(node.fileId)</oc:fileid>\
|
||||
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
"""
|
||||
}.joined()
|
||||
return """
|
||||
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">\(responses)</d:multistatus>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
final class SyncEngineTests: XCTestCase {
|
||||
private var store: SyncStore!
|
||||
private var bus: SyncStatusBus!
|
||||
private var runner: SyncRunner!
|
||||
private var fakeSession: URLSession!
|
||||
private var clock: Int64 = 1_700_000_000_000
|
||||
private let config = SyncAccountConfig(
|
||||
accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x",
|
||||
folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false)
|
||||
private var creds: SyncCredentials {
|
||||
SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||
}
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
FakeDav.reset()
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: base) }
|
||||
store = SyncStore(base: base)
|
||||
bus = SyncStatusBus()
|
||||
|
||||
let sessionConfig = URLSessionConfiguration.ephemeral
|
||||
sessionConfig.protocolClasses = [FakeDav.self]
|
||||
fakeSession = URLSession(configuration: sessionConfig)
|
||||
runner = SyncRunner(
|
||||
client: DavSyncClient(session: fakeSession),
|
||||
store: store, bus: bus, now: { [unowned self] in self.clock })
|
||||
}
|
||||
|
||||
private func state() -> [String: SyncFileState] {
|
||||
(try? store.loadState(accountId: "acct")) ?? [:]
|
||||
}
|
||||
|
||||
private var mirror: URL { store.syncRoot(accountId: "acct") }
|
||||
private func local(_ rel: String) -> String? {
|
||||
try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8)
|
||||
}
|
||||
private func exists(_ rel: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path)
|
||||
}
|
||||
private func editLocally(_ rel: String, _ text: String) throws {
|
||||
let url = mirror.appendingPathComponent(rel)
|
||||
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path)
|
||||
}
|
||||
private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count }
|
||||
|
||||
private func seedServer() {
|
||||
FakeDav.put("/Docs/a.txt", "alpha")
|
||||
FakeDav.put("/Docs/sub/b.txt", "bravo")
|
||||
}
|
||||
|
||||
// MARK: - The happy path
|
||||
|
||||
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
|
||||
seedServer()
|
||||
FakeDav.mkdir("/Docs/empty")
|
||||
let summary = await runner.run(config, force: false)
|
||||
|
||||
XCTAssertEqual(summary.downloaded, 2)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||
XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored")
|
||||
XCTAssertEqual(state().count, 2)
|
||||
XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
|
||||
XCTAssertFalse(bus.snapshot().syncing, "announced as finished")
|
||||
}
|
||||
|
||||
func testUnchangedServerIsOneCheapRequestNotAWalk() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
let walks = depth1Requests()
|
||||
|
||||
FakeDav.log = []
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertFalse(summary.changedAnything)
|
||||
XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)")
|
||||
XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"])
|
||||
}
|
||||
|
||||
func testForceAlwaysWalks() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.log = []
|
||||
_ = await runner.run(config, force: true)
|
||||
XCTAssertGreaterThan(depth1Requests(), 0)
|
||||
}
|
||||
|
||||
func testShortcutExpiresAfterTheMaxAge() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
clock += SyncRunner.fullWalkMaxAgeMs + 1
|
||||
FakeDav.log = []
|
||||
_ = await runner.run(config, force: false)
|
||||
XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever")
|
||||
}
|
||||
|
||||
func testServerChangeIsDownloaded() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.put("/Docs/a.txt", "alpha v2")
|
||||
FakeDav.put("/Docs/new.txt", "fresh")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.downloaded, 2)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha v2")
|
||||
XCTAssertEqual(local("Docs/new.txt"), "fresh")
|
||||
}
|
||||
|
||||
func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
try editLocally("Docs/a.txt", "alpha edited on phone")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.uploaded, 1)
|
||||
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone")
|
||||
|
||||
let again = await runner.run(config, force: false)
|
||||
XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change")
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone")
|
||||
}
|
||||
|
||||
func testFileDeletedLocallyIsPulledBack() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt"))
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.downloaded, 1)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server")
|
||||
}
|
||||
|
||||
// MARK: - Conflicts
|
||||
|
||||
func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.put("/Docs/a.txt", "server edit")
|
||||
try editLocally("Docs/a.txt", "phone edit")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"])
|
||||
XCTAssertEqual(summary.conflicts.first?.name, "a.txt")
|
||||
XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched")
|
||||
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched")
|
||||
XCTAssertEqual(bus.snapshot().conflicts.count, 1)
|
||||
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time")
|
||||
}
|
||||
|
||||
func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.put("/Docs/a.txt", "server edit")
|
||||
try editLocally("Docs/a.txt", "phone edit")
|
||||
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||
|
||||
let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds)
|
||||
XCTAssertTrue(ok)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "server edit")
|
||||
XCTAssertTrue(bus.snapshot().conflicts.isEmpty)
|
||||
|
||||
let next = await runner.run(config, force: false)
|
||||
XCTAssertTrue(next.conflicts.isEmpty)
|
||||
XCTAssertFalse(next.changedAnything)
|
||||
}
|
||||
|
||||
func testResolveWithLocalCopyUploadsIt() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.put("/Docs/a.txt", "server edit")
|
||||
try editLocally("Docs/a.txt", "phone edit")
|
||||
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||
|
||||
let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds)
|
||||
XCTAssertTrue(ok)
|
||||
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit")
|
||||
let next = await runner.run(config, force: false)
|
||||
XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything)
|
||||
}
|
||||
|
||||
func testAnUnknownResolutionDoesNothing() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.put("/Docs/a.txt", "server edit")
|
||||
try editLocally("Docs/a.txt", "phone edit")
|
||||
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||
|
||||
let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds)
|
||||
XCTAssertFalse(ok)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "phone edit")
|
||||
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit")
|
||||
}
|
||||
|
||||
// MARK: - Deletes (the dangerous part)
|
||||
|
||||
func testFileDeletedOnTheServerIsRemovedLocally() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.remove("/Docs/a.txt")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.deleted, 1)
|
||||
XCTAssertFalse(exists("Docs/a.txt"))
|
||||
XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes")
|
||||
}
|
||||
|
||||
func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.remove("/Docs/sub")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.deleted, 1)
|
||||
XCTAssertFalse(exists("Docs/sub/b.txt"))
|
||||
XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned")
|
||||
XCTAssertTrue(exists("Docs/a.txt"))
|
||||
}
|
||||
|
||||
func testAnUnreachableServerNeverDeletesAnything() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
let before = state()
|
||||
|
||||
FakeDav.failAll = true
|
||||
let summary = await runner.run(config, force: true)
|
||||
XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder")
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||
XCTAssertEqual(state(), before)
|
||||
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'")
|
||||
|
||||
FakeDav.failAll = false
|
||||
let recovered = await runner.run(config, force: true)
|
||||
XCTAssertFalse(recovered.changedAnything)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
}
|
||||
|
||||
func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
// Only the subfolder listing breaks: a half-built manifest must not make
|
||||
// sub/b.txt look deleted.
|
||||
FakeDav.nodes["/Docs/sub"]?.etag = "changed"
|
||||
FakeDav.nodes["/Docs"]?.etag = "changed"
|
||||
FakeDav.failPropfindPath = "/Docs/sub"
|
||||
|
||||
let summary = await runner.run(config, force: true)
|
||||
XCTAssertEqual(summary.deleted, 0)
|
||||
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
}
|
||||
|
||||
func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.remove("/Docs")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.deleted, 2)
|
||||
XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"])
|
||||
XCTAssertFalse(exists("Docs/a.txt"))
|
||||
}
|
||||
|
||||
// MARK: - Single files, scoping, housekeeping
|
||||
|
||||
func testASyncedSingleFilePath() async {
|
||||
FakeDav.put("/Notes/todo.txt", "buy milk")
|
||||
var single = config
|
||||
single.folders = ["/Notes/todo.txt"]
|
||||
|
||||
let summary = await runner.run(single, force: false)
|
||||
XCTAssertEqual(summary.downloaded, 1)
|
||||
XCTAssertEqual(local("Notes/todo.txt"), "buy milk")
|
||||
FakeDav.put("/Notes/todo.txt", "buy milk and eggs")
|
||||
_ = await runner.run(single, force: false)
|
||||
XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs")
|
||||
}
|
||||
|
||||
func testSyncOnlyTouchesTheConfiguredPaths() async {
|
||||
seedServer()
|
||||
FakeDav.put("/Other/x.txt", "not synced")
|
||||
_ = await runner.run(config, force: false)
|
||||
XCTAssertFalse(exists("Other/x.txt"))
|
||||
}
|
||||
|
||||
func testNothingConfiguredDoesNothing() async {
|
||||
seedServer()
|
||||
var empty = config
|
||||
empty.folders = []
|
||||
let summary = await runner.run(empty, force: true)
|
||||
XCTAssertEqual(summary, SyncRunSummary())
|
||||
XCTAssertTrue(FakeDav.log.isEmpty)
|
||||
}
|
||||
|
||||
func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
store.removeLocalSync(accountId: "acct", path: "/Docs/sub")
|
||||
|
||||
XCTAssertFalse(exists("Docs/sub/b.txt"))
|
||||
XCTAssertTrue(exists("Docs/a.txt"))
|
||||
XCTAssertEqual(state().count, 1)
|
||||
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.downloaded, 1)
|
||||
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||
}
|
||||
|
||||
func testStatusMarksFilesAsSyncingDuringTheRun() async {
|
||||
seedServer()
|
||||
var seen = Set<String>()
|
||||
bus.onChange = { seen.formUnion($0.syncingFileIds) }
|
||||
_ = await runner.run(config, force: false)
|
||||
XCTAssertEqual(seen.count, 2, "each transferring file was announced")
|
||||
XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty)
|
||||
}
|
||||
|
||||
func testStaleMarkersForRemovedPathsAreDropped() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
var other = config
|
||||
other.folders = ["/Other"]
|
||||
FakeDav.mkdir("/Other")
|
||||
_ = await runner.run(other, force: false)
|
||||
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
func testParserReadsEtagFileIdSizeAndDates() {
|
||||
let xml = Data(
|
||||
"""
|
||||
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/</d:href><d:propstat><d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype><d:getetag>"abc123"</d:getetag>
|
||||
<oc:fileid>7</oc:fileid></d:prop></d:propstat></d:response>
|
||||
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf</d:href><d:propstat><d:prop>
|
||||
<d:getlastmodified>Tue, 14 Nov 2023 22:13:20 GMT</d:getlastmodified>
|
||||
<d:getcontentlength>2048</d:getcontentlength><d:resourcetype/>
|
||||
<d:getetag>"e1"</d:getetag><oc:fileid>9</oc:fileid></d:prop></d:propstat></d:response>
|
||||
</d:multistatus>
|
||||
""".utf8)
|
||||
let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true)
|
||||
XCTAssertEqual(entries.count, 1, "the folder itself is skipped")
|
||||
XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf")
|
||||
XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped")
|
||||
XCTAssertEqual(entries[0].fileId, "9")
|
||||
XCTAssertEqual(entries[0].size, 2048)
|
||||
XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000)
|
||||
XCTAssertFalse(entries[0].isFolder)
|
||||
|
||||
let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false)
|
||||
XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"])
|
||||
XCTAssertTrue(all[0].isFolder)
|
||||
}
|
||||
|
||||
func testParserRefusesAnythingThatIsNotACompleteAnswer() {
|
||||
func entries(_ xml: String) throws -> [SyncRemoteEntry] {
|
||||
try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false)
|
||||
}
|
||||
XCTAssertThrowsError(try entries("nope"), "not XML")
|
||||
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php"), "truncated")
|
||||
XCTAssertThrowsError(
|
||||
try entries(
|
||||
"<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php/dav/files/alice/Docs/a.txt</d:href></d:response>"
|
||||
+ "<d:response><d:href>/remote.php/dav/files/alice/Docs/b"),
|
||||
"one complete response, then cut off: a partial manifest")
|
||||
XCTAssertThrowsError(try entries("<html><body>Maintenance</body></html>"), "well-formed but not a multistatus")
|
||||
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>"), "a 207 always has the resource itself")
|
||||
}
|
||||
|
||||
func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() {
|
||||
func parse(_ href: String) throws -> [SyncRemoteEntry] {
|
||||
let xml = "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>\(href)</d:href><d:propstat><d:prop><d:resourcetype/></d:prop></d:propstat></d:response></d:multistatus>"
|
||||
return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false)
|
||||
}
|
||||
XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt"))
|
||||
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component")
|
||||
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot")
|
||||
XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root")
|
||||
XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same")
|
||||
}
|
||||
|
||||
// MARK: - Review findings: each of these used to lose or misplace data
|
||||
|
||||
func testATruncatedAnswerIsNotAManifest() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
FakeDav.truncatePropfind = true
|
||||
|
||||
let summary = await runner.run(config, force: true)
|
||||
XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions")
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone")
|
||||
}
|
||||
|
||||
func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
try editLocally("Docs/a.txt", "my only copy of this edit")
|
||||
FakeDav.remove("/Docs/a.txt")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.deleted, 1)
|
||||
XCTAssertFalse(exists("Docs/a.txt"))
|
||||
XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked")
|
||||
|
||||
let again = await runner.run(config, force: false)
|
||||
XCTAssertFalse(exists("Docs/a.txt"), "not restored on a later sync")
|
||||
XCTAssertFalse(again.changedAnything)
|
||||
}
|
||||
|
||||
func testSyncingTheWholeAccountNoticesServerDeletions() async {
|
||||
FakeDav.put("/a.txt", "top")
|
||||
FakeDav.put("/sub/b.txt", "nested")
|
||||
var everything = config
|
||||
everything.folders = ["/"]
|
||||
|
||||
_ = await runner.run(everything, force: false)
|
||||
XCTAssertEqual(local("a.txt"), "top")
|
||||
FakeDav.log = []
|
||||
_ = await runner.run(everything, force: false)
|
||||
XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too")
|
||||
|
||||
FakeDav.remove("/a.txt")
|
||||
let summary = await runner.run(everything, force: false)
|
||||
XCTAssertEqual(summary.deleted, 1)
|
||||
XCTAssertFalse(exists("a.txt"))
|
||||
XCTAssertTrue(exists("sub/b.txt"))
|
||||
}
|
||||
|
||||
func testAPathNeverClaimsASiblingWithTheSamePrefix() {
|
||||
let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) }
|
||||
let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")]
|
||||
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"])
|
||||
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"])
|
||||
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything")
|
||||
}
|
||||
|
||||
func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async {
|
||||
FakeDav.put("/Docs/a.txt", "lower")
|
||||
FakeDav.put("/Docs/A.txt", "upper")
|
||||
FakeDav.put("/Docs/fine.txt", "ok")
|
||||
|
||||
let summary = await runner.run(config, force: false)
|
||||
XCTAssertEqual(summary.downloaded, 1, "only the file with no collision")
|
||||
XCTAssertEqual(local("Docs/fine.txt"), "ok")
|
||||
XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other")
|
||||
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean")
|
||||
let unicode = SyncDiff.collidingRelPaths([
|
||||
SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false),
|
||||
SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false),
|
||||
])
|
||||
XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"),
|
||||
"canonically equivalent Unicode spellings collide too")
|
||||
XCTAssertTrue(
|
||||
SyncDiff.collidingRelPaths([
|
||||
SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false)
|
||||
]).isEmpty, "one spelling is never a collision")
|
||||
}
|
||||
|
||||
func testServerPathsCanNeverLeaveTheMirror() {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)")
|
||||
XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root))
|
||||
XCTAssertNil(LocalFS.resolve("../outside.txt", in: root))
|
||||
XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root))
|
||||
XCTAssertNil(LocalFS.resolve("./x", in: root))
|
||||
XCTAssertNil(LocalFS.resolve("", in: root))
|
||||
XCTAssertNil(LocalFS.resolve("/", in: root))
|
||||
}
|
||||
|
||||
func testAnUnreadableStateMakesTheRunDoNothing() async throws {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
try editLocally("Docs/a.txt", "unsynced local edit")
|
||||
let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json")
|
||||
try Data("{ this is not json".utf8).write(to: stateFile)
|
||||
FakeDav.log = []
|
||||
|
||||
let summary = await runner.run(config, force: true)
|
||||
XCTAssertEqual(summary, SyncRunSummary())
|
||||
XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten")
|
||||
XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server")
|
||||
XCTAssertThrowsError(try store.loadState(accountId: "acct"))
|
||||
XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten")
|
||||
}
|
||||
|
||||
func testNoStateYetIsJustAFirstSyncNotAnError() throws {
|
||||
XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:])
|
||||
}
|
||||
|
||||
func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws {
|
||||
FakeDav.put("/Docs/a.txt", "server")
|
||||
let client = DavSyncClient(session: fakeSession)
|
||||
let folder = mirror.appendingPathComponent("Docs/a.txt")
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder)
|
||||
XCTAssertFalse(ok, "a folder is not replaced by a file")
|
||||
XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside")
|
||||
|
||||
let file = mirror.appendingPathComponent("Docs/b.txt")
|
||||
try "old".write(to: file, atomically: true, encoding: .utf8)
|
||||
FakeDav.put("/Docs/b.txt", "new")
|
||||
let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
|
||||
XCTAssertTrue(replaced)
|
||||
XCTAssertEqual(local("Docs/b.txt"), "new")
|
||||
FakeDav.failAll = true
|
||||
let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
|
||||
XCTAssertFalse(failed)
|
||||
XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there")
|
||||
}
|
||||
|
||||
func testRemovingTheWholeRootForgetsEveryFile() async {
|
||||
seedServer()
|
||||
_ = await runner.run(config, force: false)
|
||||
store.removeLocalSync(accountId: "acct", path: "/")
|
||||
XCTAssertTrue(state().isEmpty, "'/' covers everything recorded")
|
||||
XCTAssertFalse(exists("Docs/a.txt"))
|
||||
}
|
||||
|
||||
func testPruningNeverRemovesAFolderThatHoldsSomething() throws {
|
||||
let root = mirror
|
||||
try FileManager.default.createDirectory(
|
||||
at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true)
|
||||
try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8)
|
||||
try FileManager.default.createDirectory(
|
||||
at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true)
|
||||
|
||||
SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"])
|
||||
XCTAssertEqual(local("Docs/stale/note.txt"), "mine")
|
||||
XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes")
|
||||
}
|
||||
|
||||
// MARK: - Status, coordinator, config store, mutex
|
||||
|
||||
func testConflictsAreKeptPerAccount() {
|
||||
let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x")
|
||||
let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y")
|
||||
bus.addConflicts(accountId: "A", [a])
|
||||
bus.addConflicts(accountId: "B", [b])
|
||||
XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept")
|
||||
|
||||
let coordinator = SyncCoordinator(
|
||||
store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient())
|
||||
let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]]
|
||||
XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts")
|
||||
|
||||
bus.removeConflict(accountId: "A", fileId: "7")
|
||||
XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's")
|
||||
}
|
||||
|
||||
func testBackgroundSlotOnlyRunsAccountsWithPeriodicSyncEnabled() async {
|
||||
seedServer()
|
||||
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
|
||||
addTeardownBlock { configs.removeAll() }
|
||||
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient(session: fakeSession))
|
||||
var scheduled = config
|
||||
scheduled.intervalMinutes = nil
|
||||
scheduled.wifiOnly = false
|
||||
configs.upsert(scheduled)
|
||||
|
||||
let skipped = expectation(description: "manual-only account skipped")
|
||||
coordinator.startBackgroundSlot { success in
|
||||
XCTAssertTrue(success)
|
||||
skipped.fulfill()
|
||||
}
|
||||
await fulfillment(of: [skipped], timeout: 10)
|
||||
XCTAssertFalse(exists("Docs/a.txt"))
|
||||
|
||||
scheduled.intervalMinutes = 30
|
||||
configs.upsert(scheduled)
|
||||
let completed = expectation(description: "periodic account synced")
|
||||
coordinator.startBackgroundSlot { success in
|
||||
XCTAssertTrue(success)
|
||||
completed.fulfill()
|
||||
}
|
||||
await fulfillment(of: [completed], timeout: 15)
|
||||
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||
}
|
||||
|
||||
func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() {
|
||||
let service = "test-\(UUID().uuidString)"
|
||||
let configs = SyncConfigStore(service: service)
|
||||
addTeardownBlock { configs.removeAll() }
|
||||
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient())
|
||||
var withBackground = config
|
||||
withBackground.intervalMinutes = 30
|
||||
|
||||
coordinator.reschedule(withBackground)
|
||||
XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30)
|
||||
|
||||
coordinator.cancel(accountId: "acct", forget: false)
|
||||
XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials")
|
||||
XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled")
|
||||
|
||||
coordinator.cancel(accountId: "acct", forget: true)
|
||||
XCTAssertNil(configs.config(for: "acct"), "signing out forgets them")
|
||||
}
|
||||
|
||||
func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() {
|
||||
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
|
||||
addTeardownBlock { configs.removeAll() }
|
||||
let second = SyncAccountConfig(
|
||||
accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y",
|
||||
folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true)
|
||||
|
||||
XCTAssertTrue(configs.upsert(config))
|
||||
XCTAssertTrue(configs.upsert(second))
|
||||
XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"])
|
||||
|
||||
var changed = config
|
||||
changed.folders = ["/Docs", "/More"]
|
||||
XCTAssertTrue(configs.upsert(changed))
|
||||
XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"])
|
||||
XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others")
|
||||
|
||||
XCTAssertTrue(configs.remove(accountId: "acct"))
|
||||
XCTAssertEqual(configs.all().map(\.accountId), ["other"])
|
||||
XCTAssertTrue(configs.remove(accountId: "never-existed"))
|
||||
}
|
||||
|
||||
func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws {
|
||||
let mutex = AsyncMutex()
|
||||
let holderStarted = expectation(description: "holder has the lock")
|
||||
let holder = Task {
|
||||
try await mutex.withLock {
|
||||
holderStarted.fulfill()
|
||||
try await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
}
|
||||
}
|
||||
await fulfillment(of: [holderStarted], timeout: 5)
|
||||
|
||||
let waiter = Task { try await mutex.withLock { "ran" } }
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
let cancelledAt = Date()
|
||||
waiter.cancel()
|
||||
do {
|
||||
_ = try await waiter.value
|
||||
XCTFail("a cancelled waiter must not run")
|
||||
} catch {
|
||||
XCTAssertTrue(error is CancellationError)
|
||||
XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder")
|
||||
}
|
||||
|
||||
try await holder.value
|
||||
let next = try await mutex.withLock { "next" }
|
||||
XCTAssertEqual(next, "next", "the lock is free for the next one")
|
||||
}
|
||||
|
||||
private final class Counter: @unchecked Sendable {
|
||||
var running = 0
|
||||
var maxRunning = 0
|
||||
var finished = 0
|
||||
}
|
||||
|
||||
func testAMutexNeverRunsTwoBodiesAtOnce() async throws {
|
||||
let mutex = AsyncMutex()
|
||||
let counter = Counter()
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<6 {
|
||||
group.addTask {
|
||||
_ = try? await mutex.withLock {
|
||||
counter.running += 1
|
||||
counter.maxRunning = max(counter.maxRunning, counter.running)
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
counter.running -= 1
|
||||
counter.finished += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(counter.finished, 6)
|
||||
XCTAssertEqual(counter.maxRunning, 1)
|
||||
}
|
||||
|
||||
func testOnceGateCompletesOnlyOnce() {
|
||||
var calls: [Bool] = []
|
||||
let gate = OnceGate { calls.append($0) }
|
||||
gate.finish(false)
|
||||
gate.finish(true)
|
||||
XCTAssertEqual(calls, [false])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import LocalAuthentication
|
||||
|
||||
/// Face ID / Touch ID with the device passcode as the fallback - the same
|
||||
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
|
||||
/// the share sheet asks for exactly what the app would.
|
||||
enum DeviceAuth {
|
||||
/// True only if the user actually authenticated; a cancel, a failure, or a
|
||||
/// device with nothing to authenticate with all count as "not unlocked".
|
||||
static func authenticate(reason: String) async -> Bool {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
|
||||
return await withCheckedContinuation { continuation in
|
||||
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Noo</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
|
||||
<key>NooKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>50</integer>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>50</integer>
|
||||
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||
<integer>50</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.ayushya.noo</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,406 @@
|
||||
import SwiftUI
|
||||
|
||||
/// State behind the share sheet's account and folder pickers.
|
||||
@MainActor
|
||||
final class ShareModel: ObservableObject {
|
||||
enum Stage: Equatable {
|
||||
case preparing
|
||||
case chooseAccount
|
||||
case picking
|
||||
case uploading
|
||||
case noAccount
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published var stage: Stage = .preparing
|
||||
@Published var items: [SharedItem] = []
|
||||
@Published var path = "/"
|
||||
@Published var isLoadingFolders = false
|
||||
@Published var folderError: String?
|
||||
/// A short explanation shown under the list - e.g. hidden folders stayed
|
||||
/// hidden because the unlock was cancelled.
|
||||
@Published var notice: String?
|
||||
@Published private(set) var selected: SharedAccount?
|
||||
|
||||
/// The sheet's two toggles. They start at the app's own setting for the
|
||||
/// chosen account and can be changed for this share only.
|
||||
@Published private(set) var hiddenFilter: HiddenFilter = .hide
|
||||
@Published private(set) var storageFilter: StorageFilter = .cloud
|
||||
|
||||
/// Every child folder of [path], unfiltered; the toggles filter it live.
|
||||
@Published private var allFolders: [DavFolder] = []
|
||||
/// Mount points of external storage seen so far. Only the mount's root is
|
||||
/// flagged by the server, so anything under one of these is external too.
|
||||
private var externalRoots: Set<String> = []
|
||||
|
||||
let shared: SharedAccounts?
|
||||
|
||||
/// Set by the view controller: what each button actually does.
|
||||
var onUpload: () -> Void = {}
|
||||
var onSaveForLater: () -> Void = {}
|
||||
var onCancel: () -> Void = {}
|
||||
|
||||
init(shared: SharedAccounts?) {
|
||||
self.shared = shared
|
||||
}
|
||||
|
||||
var accounts: [SharedAccount] { shared?.accounts ?? [] }
|
||||
var canChangeAccount: Bool { accounts.count > 1 }
|
||||
var activeAccountId: String? { shared?.active?.id }
|
||||
|
||||
var currentFolderName: String {
|
||||
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
var fileCountText: String {
|
||||
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||
}
|
||||
|
||||
// MARK: - Folders, filtered by the toggles
|
||||
|
||||
private func isExternal(_ folder: DavFolder) -> Bool {
|
||||
folder.isExternal
|
||||
|| externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") }
|
||||
}
|
||||
|
||||
private var visibleFolders: [DavFolder] {
|
||||
allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) }
|
||||
}
|
||||
|
||||
/// Internal folders - and, for "All", the external ones are listed apart.
|
||||
var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } }
|
||||
var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } }
|
||||
|
||||
// MARK: - Accounts
|
||||
|
||||
/// With several accounts the list comes first; with one it goes straight
|
||||
/// to its folders.
|
||||
func start() async {
|
||||
guard let shared, let first = shared.accounts.first else {
|
||||
stage = .noAccount
|
||||
return
|
||||
}
|
||||
NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none")
|
||||
if shared.accounts.count > 1 {
|
||||
stage = .chooseAccount
|
||||
} else {
|
||||
await select(first)
|
||||
}
|
||||
}
|
||||
|
||||
func showAccounts() {
|
||||
notice = nil
|
||||
stage = .chooseAccount
|
||||
}
|
||||
|
||||
/// Uploading to an account other than the one the app is on is "switching"
|
||||
/// in the app's terms, so it asks for the same unlock when the app does.
|
||||
/// The toggles start at that account's own app settings; hidden folders
|
||||
/// are behind the app's "lock hidden files" unlock.
|
||||
func select(_ account: SharedAccount) async {
|
||||
guard let shared else { return }
|
||||
notice = nil
|
||||
var wantedHidden = HiddenFilter(raw: account.hiddenFilter)
|
||||
|
||||
// Every selection asks again, like the app does for every switch - no
|
||||
// unlock is remembered. If one action needs both unlocks, one prompt
|
||||
// covers both.
|
||||
let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden)
|
||||
if needs.needsPrompt {
|
||||
let reason = needs.switchesAccount
|
||||
? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders"
|
||||
if await !DeviceAuth.authenticate(reason: reason) {
|
||||
guard !needs.switchesAccount else {
|
||||
notice = "Unlock to upload to a different account."
|
||||
stage = .chooseAccount
|
||||
return
|
||||
}
|
||||
wantedHidden = .hide
|
||||
notice = "Hidden folders are locked, so they're not shown."
|
||||
}
|
||||
}
|
||||
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope)
|
||||
selected = account
|
||||
externalRoots = []
|
||||
storageFilter = StorageFilter(raw: account.storageScope)
|
||||
hiddenFilter = wantedHidden
|
||||
stage = .picking
|
||||
await open("/")
|
||||
}
|
||||
|
||||
// MARK: - Toggles
|
||||
|
||||
/// Turning hidden folders *on* is what the app locks; going back to
|
||||
/// hiding them never exposes anything, and neither does switching
|
||||
/// between the two revealing modes.
|
||||
func setHiddenFilter(_ filter: HiddenFilter) async {
|
||||
guard filter != hiddenFilter else { return }
|
||||
if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true {
|
||||
guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else {
|
||||
notice = "Hidden folders are locked, so they're not shown."
|
||||
return
|
||||
}
|
||||
}
|
||||
notice = nil
|
||||
hiddenFilter = filter
|
||||
await open("/")
|
||||
}
|
||||
|
||||
func setStorageFilter(_ filter: StorageFilter) async {
|
||||
guard filter != storageFilter else { return }
|
||||
notice = nil
|
||||
storageFilter = filter
|
||||
await open("/")
|
||||
}
|
||||
|
||||
// MARK: - Browsing
|
||||
|
||||
/// A toggle can leave you inside a folder it now hides, so changing one
|
||||
/// always returns to the top - same as [open] with "/".
|
||||
func open(_ path: String) async {
|
||||
guard let account = selected else { return }
|
||||
self.path = path
|
||||
isLoadingFolders = true
|
||||
folderError = nil
|
||||
defer { isLoadingFolders = false }
|
||||
do {
|
||||
allFolders = try await DavClient.listFolders(account: account, path: path)
|
||||
externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path))
|
||||
} catch {
|
||||
allFolders = []
|
||||
folderError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func openParent() async {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
await open(parent.isEmpty ? "/" : parent)
|
||||
}
|
||||
}
|
||||
|
||||
struct SharePickerView: View {
|
||||
@ObservedObject var model: ShareModel
|
||||
@State private var externalExpanded = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Button("Cancel", action: model.onCancel)
|
||||
Spacer()
|
||||
Text("Upload to Noo").font(.headline)
|
||||
Spacer()
|
||||
// Balances Cancel so the title stays centred.
|
||||
Button("Cancel", action: {}).hidden()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch model.stage {
|
||||
case .preparing:
|
||||
centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") }
|
||||
case .uploading:
|
||||
centered {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Uploading \(model.fileCountText) in the background").font(.subheadline)
|
||||
}
|
||||
}
|
||||
case .noAccount:
|
||||
centered {
|
||||
Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
}
|
||||
case .failed(let message):
|
||||
centered {
|
||||
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
||||
}
|
||||
case .chooseAccount:
|
||||
accountList
|
||||
case .picking:
|
||||
folderList
|
||||
}
|
||||
}
|
||||
|
||||
private var accountList: some View {
|
||||
List {
|
||||
Section(
|
||||
header: Text("Upload \(model.fileCountText) to which account?"),
|
||||
footer: model.notice.map { Text($0) }
|
||||
) {
|
||||
ForEach(model.accounts) { account in
|
||||
Button {
|
||||
Task { await model.select(account) }
|
||||
} label: {
|
||||
HStack {
|
||||
Label(account.displayName, systemImage: "person.crop.circle")
|
||||
Spacer()
|
||||
if account.id == model.activeAccountId {
|
||||
Text("Active").font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
// MARK: Folders
|
||||
|
||||
private var folderList: some View {
|
||||
List {
|
||||
Section(header: accountHeader, footer: model.notice.map { Text($0) }) {
|
||||
filterRow
|
||||
if model.path != "/" {
|
||||
Button {
|
||||
Task { await model.openParent() }
|
||||
} label: {
|
||||
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
|
||||
}
|
||||
}
|
||||
if model.isLoadingFolders {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
} else if let error = model.folderError {
|
||||
Text(error).foregroundColor(.secondary)
|
||||
} else if model.internalFolders.isEmpty && model.externalFolders.isEmpty {
|
||||
Text("No folders here").foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(model.internalFolders, id: \.path) { folderRow($0) }
|
||||
}
|
||||
}
|
||||
// "All" keeps external storage in its own group, like the app's list.
|
||||
if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $externalExpanded) {
|
||||
ForEach(model.externalFolders, id: \.path) { folderRow($0) }
|
||||
} label: {
|
||||
Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private func folderRow(_ folder: DavFolder) -> some View {
|
||||
Button {
|
||||
Task { await model.open(folder.path) }
|
||||
} label: {
|
||||
Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder")
|
||||
}
|
||||
}
|
||||
|
||||
/// The two toggles, as menus - Hidden folders and External storage each
|
||||
/// start at the app's setting and can be changed for this share.
|
||||
private var filterRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
Picker("Hidden folders", selection: hiddenBinding) {
|
||||
Text("Hide hidden folders").tag(HiddenFilter.hide)
|
||||
Text("Only hidden folders").tag(HiddenFilter.only)
|
||||
Text("Show all").tag(HiddenFilter.include)
|
||||
}
|
||||
} label: {
|
||||
chip("eye", "Hidden", hiddenTitle)
|
||||
}
|
||||
Menu {
|
||||
Picker("External storage", selection: storageBinding) {
|
||||
Text("Cloud only").tag(StorageFilter.cloud)
|
||||
Text("Only external storage").tag(StorageFilter.external)
|
||||
Text("All + external storage").tag(StorageFilter.all)
|
||||
}
|
||||
} label: {
|
||||
chip("externaldrive", "Storage", storageTitle)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
private func chip(_ icon: String, _ name: String, _ value: String) -> some View {
|
||||
Label("\(name): \(value)", systemImage: icon)
|
||||
.font(.footnote.weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color(.secondarySystemFill)))
|
||||
}
|
||||
|
||||
private var hiddenBinding: Binding<HiddenFilter> {
|
||||
Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } })
|
||||
}
|
||||
|
||||
private var storageBinding: Binding<StorageFilter> {
|
||||
Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } })
|
||||
}
|
||||
|
||||
private var hiddenTitle: String {
|
||||
switch model.hiddenFilter {
|
||||
case .hide: return "Hide"
|
||||
case .only: return "Only"
|
||||
case .include: return "All"
|
||||
}
|
||||
}
|
||||
|
||||
private var storageTitle: String {
|
||||
switch model.storageFilter {
|
||||
case .cloud: return "Cloud"
|
||||
case .external: return "External"
|
||||
case .all: return "All"
|
||||
}
|
||||
}
|
||||
|
||||
private var accountHeader: some View {
|
||||
HStack {
|
||||
Text(model.selected?.displayName ?? "")
|
||||
Spacer()
|
||||
if model.canChangeAccount {
|
||||
Button("Change account") { model.showAccounts() }
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var parentName: String {
|
||||
let parent = (model.path as NSString).deletingLastPathComponent
|
||||
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 8) {
|
||||
if model.stage == .picking {
|
||||
Button(action: model.onUpload) {
|
||||
Text("Upload \(model.fileCountText) to \(model.currentFolderName)")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.isLoadingFolders)
|
||||
}
|
||||
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
|
||||
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func centered<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import UserNotifications
|
||||
|
||||
/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is
|
||||
/// chosen *inside* the sheet - an extension can't open its host app - so this
|
||||
/// shows a folder picker, then starts a background upload and closes:
|
||||
///
|
||||
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
||||
/// 2. Reads the accounts the app left in the shared Keychain
|
||||
/// (`SharedAccountStore`) - asking which one first if there are several -
|
||||
/// and lists folders over WebDAV (`DavClient`), honouring the app's
|
||||
/// hidden-files filter and its unlock settings (`ShareModel`).
|
||||
/// 3. "Upload" queues the files on a background session that outlives this
|
||||
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
||||
///
|
||||
/// With no account, or if the user prefers, the files are instead left in the
|
||||
/// inbox for the app to offer its own destination picker next time it opens
|
||||
/// (with a notification saying so).
|
||||
final class ShareViewController: UIViewController {
|
||||
private var model: ShareModel!
|
||||
private var batchDirectory: URL?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
model = ShareModel(shared: SharedAccountStore.load())
|
||||
model.onUpload = { [weak self] in self?.upload() }
|
||||
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
||||
model.onCancel = { [weak self] in self?.cancel() }
|
||||
|
||||
let host = UIHostingController(rootView: SharePickerView(model: model))
|
||||
addChild(host)
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(host.view)
|
||||
NSLayoutConstraint.activate([
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
|
||||
Task { await prepare() }
|
||||
}
|
||||
|
||||
// MARK: - Importing
|
||||
|
||||
@MainActor
|
||||
private func prepare() async {
|
||||
guard let root = AppGroup.containerURL else {
|
||||
model.stage = .failed("Noo isn't set up to receive files yet.")
|
||||
return
|
||||
}
|
||||
let providers =
|
||||
(extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? []
|
||||
|
||||
do {
|
||||
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||
batchDirectory = batch
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) {
|
||||
if let item = try? await copy(provider, into: batch) { model.items.append(item) }
|
||||
}
|
||||
} catch {
|
||||
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
guard !model.items.isEmpty else {
|
||||
model.stage = .failed("Noo can only receive files and photos.")
|
||||
return
|
||||
}
|
||||
await model.start()
|
||||
}
|
||||
|
||||
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||
/// callback, so the copy happens inside it.
|
||||
private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
|
||||
guard let url else {
|
||||
return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
|
||||
}
|
||||
do {
|
||||
let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0
|
||||
let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType
|
||||
continuation.resume(
|
||||
returning: SharedItem(
|
||||
path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func upload() {
|
||||
guard let account = model.selected else { return }
|
||||
do {
|
||||
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||
model.stage = .uploading
|
||||
complete(after: 1.2)
|
||||
} catch {
|
||||
model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveForLater() {
|
||||
guard let root = AppGroup.containerURL else { return cancel() }
|
||||
do {
|
||||
try SharedInbox.append(model.items, in: root)
|
||||
notifyOpenNoo(count: model.items.count)
|
||||
complete(after: 0)
|
||||
} catch {
|
||||
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) }
|
||||
extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))
|
||||
}
|
||||
|
||||
private func complete(after delay: TimeInterval) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
self.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyOpenNoo(count: Int) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Noo"
|
||||
content.body =
|
||||
count == 1
|
||||
? "Open Noo to choose where to upload 1 file."
|
||||
: "Open Noo to choose where to upload \(count) files."
|
||||
content.sound = .default
|
||||
UNUserNotificationCenter.current().add(
|
||||
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
/// A folder in the account's WebDAV tree. [path] is relative to the user's
|
||||
/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root.
|
||||
/// [isExternal] is true for an external-storage mount point (Nextcloud's
|
||||
/// `nc:mount-type` = `external`) - only the mount's root carries it, its
|
||||
/// children don't, so callers track mount roots to know what's inside one.
|
||||
struct DavFolder: Equatable {
|
||||
let name: String
|
||||
let path: String
|
||||
var isExternal = false
|
||||
}
|
||||
|
||||
/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud")
|
||||
/// folders only (the default), only external-storage ones, or both.
|
||||
enum StorageFilter: String {
|
||||
case cloud, external, all
|
||||
|
||||
init(raw: String) {
|
||||
self = StorageFilter(rawValue: raw) ?? .cloud
|
||||
}
|
||||
|
||||
func shows(isExternal: Bool) -> Bool {
|
||||
switch self {
|
||||
case .cloud: return !isExternal
|
||||
case .external: return isExternal
|
||||
case .all: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
|
||||
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
|
||||
/// list everything (`include`). A folder counts as hidden when it - or any
|
||||
/// folder above it - starts with a dot, exactly as in the app.
|
||||
enum HiddenFilter: String {
|
||||
case hide, only, include
|
||||
|
||||
init(raw: String) {
|
||||
self = HiddenFilter(rawValue: raw) ?? .hide
|
||||
}
|
||||
|
||||
static func isHidden(path: String) -> Bool {
|
||||
path.split(separator: "/").contains { $0.hasPrefix(".") }
|
||||
}
|
||||
|
||||
func shows(path: String) -> Bool {
|
||||
switch self {
|
||||
case .hide: return !Self.isHidden(path: path)
|
||||
case .only: return Self.isHidden(path: path)
|
||||
case .include: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
||||
/// networking), so it's unit-tested. Compiled into both targets.
|
||||
enum DavFolderParser {
|
||||
private struct Response {
|
||||
var href = ""
|
||||
var isCollection = false
|
||||
var mountType = ""
|
||||
}
|
||||
|
||||
private final class Delegate: NSObject, XMLParserDelegate {
|
||||
var responses: [Response] = []
|
||||
private var current: Response?
|
||||
private var text = ""
|
||||
|
||||
private func local(_ name: String) -> String {
|
||||
name.split(separator: ":").last.map(String.init) ?? name
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
switch local(elementName) {
|
||||
case "response": current = Response()
|
||||
case "href", "mount-type": text = ""
|
||||
case "collection": current?.isCollection = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
text += string
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
switch local(elementName) {
|
||||
case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
case "response":
|
||||
if let current { responses.append(current) }
|
||||
current = nil
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms".
|
||||
/// The server may live under a sub-path, so the files root is located by
|
||||
/// its marker instead of assumed to start at the beginning.
|
||||
static func relativePath(fromHref href: String) -> String? {
|
||||
let decoded = href.removingPercentEncoding ?? href
|
||||
let marker = "/remote.php/dav/files/"
|
||||
guard let range = decoded.range(of: marker) else { return nil }
|
||||
var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
parts.removeFirst() // the username
|
||||
return "/" + parts.joined(separator: "/")
|
||||
}
|
||||
|
||||
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
|
||||
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
|
||||
/// always left out.
|
||||
static func folders(
|
||||
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
|
||||
) -> [DavFolder] {
|
||||
let delegate = Delegate()
|
||||
let parser = XMLParser(data: xml)
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
|
||||
let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
|
||||
return delegate.responses
|
||||
.filter(\.isCollection)
|
||||
.compactMap { response -> DavFolder? in
|
||||
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
|
||||
let name = (path as NSString).lastPathComponent
|
||||
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
|
||||
return DavFolder(name: name, path: path, isExternal: response.mountType == "external")
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders over WebDAV for the Share Extension's picker, in the
|
||||
/// foreground (the sheet is on screen while it runs).
|
||||
enum DavClient {
|
||||
private static let body = """
|
||||
<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"><d:prop><d:resourcetype/><nc:mount-type/></d:prop></d:propfind>
|
||||
"""
|
||||
|
||||
/// Every child folder, hidden and external ones included - the sheet's
|
||||
/// toggles filter that list in memory, so changing one doesn't refetch.
|
||||
/// Two accounts can live on the same server, so a request must be
|
||||
/// authenticated by its own `Authorization` header alone - never by a
|
||||
/// session cookie or credential a previous account's request left behind
|
||||
/// in the shared stores, or the second account could be answered as the
|
||||
/// first.
|
||||
private static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.httpShouldSetCookies = false
|
||||
config.httpCookieAcceptPolicy = .never
|
||||
config.urlCredentialStorage = nil
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
|
||||
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
|
||||
else { throw TransferError(message: "Invalid server address.") }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PROPFIND"
|
||||
request.setValue("1", forHTTPHeaderField: "Depth")
|
||||
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
request.timeoutInterval = 20
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
NSLog("[DavClient] PROPFIND %@ as %@ -> %d", url.path, account.username, status)
|
||||
guard status == 207 else {
|
||||
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
|
||||
}
|
||||
return DavFolderParser.folders(from: data, excluding: path, hidden: .include)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Starts the Share Extension's uploads on a background `URLSession` that
|
||||
/// outlives the extension. The extension process is torn down as soon as its
|
||||
/// sheet closes; because the session has a `sharedContainerIdentifier` and
|
||||
/// the same identifier is recreated by the app at launch
|
||||
/// (`TransferManager.reconnect`), the *app* is what gets relaunched to
|
||||
/// receive the results, post the summary notification and clean up.
|
||||
/// Compiled into both targets.
|
||||
enum ShareUpload {
|
||||
static let sessionIdentifier = "dev.ayushya.noo.transfers.share"
|
||||
|
||||
static func configuration() -> URLSessionConfiguration {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
|
||||
config.sharedContainerIdentifier = AppGroup.identifier
|
||||
config.sessionSendsLaunchEvents = true
|
||||
config.isDiscretionary = false
|
||||
config.waitsForConnectivity = true
|
||||
// Authenticate by the request's own header only (see DavClient.session).
|
||||
config.httpShouldSetCookies = false
|
||||
config.httpCookieAcceptPolicy = .never
|
||||
config.urlCredentialStorage = nil
|
||||
return config
|
||||
}
|
||||
|
||||
/// A `PUT` for [item] into [remoteFolder] - shared by the extension's
|
||||
/// [enqueue] and the app's own uploads so both build requests identically.
|
||||
static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? {
|
||||
guard
|
||||
let url = WebDAV.fileURL(
|
||||
serverUrl: account.serverUrl,
|
||||
username: account.username,
|
||||
remotePath: WebDAV.join(remoteFolder, item.name)
|
||||
)
|
||||
else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||
if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date {
|
||||
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/// Queues every item (already copied into the App Group by the extension)
|
||||
/// and returns once the system has accepted them.
|
||||
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
||||
guard !items.isEmpty else { return }
|
||||
let batch = UUID().uuidString
|
||||
// Small files can finish while the extension is still on screen; for
|
||||
// those this delegate counts them. Whatever outlives the extension is
|
||||
// reported to the app instead (see `TransferManager`).
|
||||
let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil)
|
||||
|
||||
var tasks: [URLSessionUploadTask] = []
|
||||
for item in items {
|
||||
guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else {
|
||||
throw TransferError(message: "Invalid server address.")
|
||||
}
|
||||
let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path))
|
||||
task.taskDescription =
|
||||
(try? JSONEncoder().encode(
|
||||
TransferTaskInfo(
|
||||
kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path)
|
||||
)).flatMap { String(data: $0, encoding: .utf8) }
|
||||
tasks.append(task)
|
||||
}
|
||||
TransferBatchStore.save(
|
||||
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||
id: batch)
|
||||
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||
tasks.forEach { $0.resume() }
|
||||
session.finishTasksAndInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
struct TransferError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// One signed-in account, as much of it as the Share Extension needs to list
|
||||
/// folders and upload. Compiled into both targets.
|
||||
struct SharedAccount: Codable, Equatable, Identifiable {
|
||||
let id: String
|
||||
let serverUrl: String
|
||||
let username: String
|
||||
/// `Basic ...` - the app password never leaves the Keychain as plain text.
|
||||
let authHeader: String
|
||||
/// Shown in the picker, e.g. "alice@cloud.example.com".
|
||||
let displayName: String
|
||||
/// The app's Files "hidden files" filter for this account - `hide` (the
|
||||
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
|
||||
/// follows it instead of having a setting of its own.
|
||||
let hiddenFilter: String
|
||||
/// The app's storage scope for this account - `cloud` (the default),
|
||||
/// `external` or `all` (see `StorageFilter`).
|
||||
let storageScope: String
|
||||
|
||||
init(
|
||||
id: String, serverUrl: String, username: String, authHeader: String, displayName: String,
|
||||
hiddenFilter: String = "hide", storageScope: String = "cloud"
|
||||
) {
|
||||
self.id = id
|
||||
self.serverUrl = serverUrl
|
||||
self.username = username
|
||||
self.authHeader = authHeader
|
||||
self.displayName = displayName
|
||||
self.hiddenFilter = hiddenFilter
|
||||
self.storageScope = storageScope
|
||||
}
|
||||
|
||||
/// Tolerates data written before a field existed instead of failing the
|
||||
/// whole decode (which would look like "signed out" to the extension).
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
id: try c.decode(String.self, forKey: .id),
|
||||
serverUrl: try c.decode(String.self, forKey: .serverUrl),
|
||||
username: try c.decode(String.self, forKey: .username),
|
||||
authHeader: try c.decode(String.self, forKey: .authHeader),
|
||||
displayName: try c.decode(String.self, forKey: .displayName),
|
||||
hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide",
|
||||
storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the app publishes for the extension: every account that can
|
||||
/// upload, which one is active in the app, and the app-lock settings the
|
||||
/// extension has to honour. The app rewrites it whenever any of that changes.
|
||||
struct SharedAccounts: Codable, Equatable {
|
||||
var accounts: [SharedAccount]
|
||||
var activeId: String?
|
||||
/// Settings -> Security: the three independent locks. `loginLockEnabled`
|
||||
/// (unlock to open the app) is carried for completeness; the extension's own
|
||||
/// gates are the other two.
|
||||
var loginLockEnabled: Bool
|
||||
var lockAccountSwitching: Bool
|
||||
var lockHiddenFiles: Bool
|
||||
|
||||
/// The account the app is currently using, else the first one.
|
||||
var active: SharedAccount? {
|
||||
accounts.first { $0.id == activeId } ?? accounts.first
|
||||
}
|
||||
|
||||
/// Uploading to an account other than the active one is "switching" in
|
||||
/// the app's terms, so it needs the same unlock.
|
||||
var needsUnlockToSwitchAccount: Bool { lockAccountSwitching }
|
||||
|
||||
/// Showing hidden folders needs the same unlock the app asks for when you
|
||||
/// turn hidden files on.
|
||||
var needsUnlockForHidden: Bool { lockHiddenFiles }
|
||||
|
||||
/// What choosing [account] (with [hidden] folders to show) asks the user
|
||||
/// to unlock. One action, one prompt - when both the account switch and
|
||||
/// the hidden folders need it, a single authentication covers both.
|
||||
func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock {
|
||||
SelectionUnlock(
|
||||
switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount,
|
||||
showsHidden: hidden != .hide && needsUnlockForHidden)
|
||||
}
|
||||
|
||||
/// Turning hidden folders on (from `hide`) is what the app locks; going
|
||||
/// back to `hide`, or between the two revealing modes, never asks.
|
||||
func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool {
|
||||
old == .hide && new != .hide && needsUnlockForHidden
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
|
||||
/// extension are entitled to (`keychain-access-groups`). The group's full id
|
||||
/// carries the signing team's prefix, so it's injected into each target's
|
||||
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
|
||||
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
|
||||
/// then always agree on it, with or without a team.
|
||||
enum SharedAccountStore {
|
||||
private static let service = "dev.ayushya.noo.shared-accounts"
|
||||
private static let account = "all"
|
||||
|
||||
static var accessGroup: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
||||
}
|
||||
|
||||
private static func baseQuery() -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
if let group = accessGroup, !group.isEmpty {
|
||||
query[kSecAttrAccessGroup as String] = group
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
static func save(_ value: SharedAccounts) throws {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
clear()
|
||||
var query = baseQuery()
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> SharedAccounts? {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
|
||||
!decoded.accounts.isEmpty
|
||||
else { return nil }
|
||||
return decoded
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
SecItemDelete(baseQuery() as CFDictionary)
|
||||
// The first version kept a single account under its own service name;
|
||||
// don't leave that credential behind.
|
||||
var legacy = baseQuery()
|
||||
legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account"
|
||||
legacy[kSecAttrAccount as String] = "active"
|
||||
SecItemDelete(legacy as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`.
|
||||
struct SelectionUnlock: Equatable {
|
||||
let switchesAccount: Bool
|
||||
let showsHidden: Bool
|
||||
|
||||
var needsPrompt: Bool { switchesAccount || showsHidden }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
|
||||
/// The App Group both the app and its Share Extension belong to - the one
|
||||
/// place they can exchange files.
|
||||
enum AppGroup {
|
||||
static let identifier = "group.dev.ayushya.noo"
|
||||
|
||||
static var containerURL: URL? {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
/// One file the Share Extension copied into the group container.
|
||||
struct SharedItem: Codable, Equatable {
|
||||
let path: String
|
||||
let name: String
|
||||
let mimeType: String?
|
||||
let size: Int64
|
||||
}
|
||||
|
||||
/// Files the Share Extension received, waiting for the app to ask the user
|
||||
/// where to upload them. The extension appends; the app consumes (reads and
|
||||
/// clears) when it launches or becomes active. Compiled into both targets.
|
||||
///
|
||||
/// Layout under `<root>/SharedInbox/`: `pending.json` (the manifest) and one
|
||||
/// `<batch-id>/` folder per share holding the copied files.
|
||||
enum SharedInbox {
|
||||
static let folderName = "SharedInbox"
|
||||
private static let manifestName = "pending.json"
|
||||
|
||||
static func directory(in root: URL) -> URL {
|
||||
root.appendingPathComponent(folderName, isDirectory: true)
|
||||
}
|
||||
|
||||
/// A fresh folder for one share's files.
|
||||
static func makeBatchDirectory(in root: URL) throws -> URL {
|
||||
let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
static func append(_ items: [SharedItem], in root: URL) throws {
|
||||
guard !items.isEmpty else { return }
|
||||
try withManifestLock(in: root) {
|
||||
let existing = (try? readManifest(in: root)) ?? []
|
||||
try writeManifest(existing + items, in: root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything pending, dropping entries whose file has gone missing, and
|
||||
/// clears the manifest so each share is delivered once.
|
||||
static func consume(in root: URL) -> [SharedItem] {
|
||||
var items: [SharedItem] = []
|
||||
try? withManifestLock(in: root) {
|
||||
items = ((try? readManifest(in: root)) ?? []).filter {
|
||||
FileManager.default.fileExists(atPath: $0.path)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: manifestURL(in: root))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func manifestURL(in root: URL) -> URL {
|
||||
directory(in: root).appendingPathComponent(manifestName)
|
||||
}
|
||||
|
||||
private static func readManifest(in root: URL) throws -> [SharedItem] {
|
||||
let data = try Data(contentsOf: manifestURL(in: root))
|
||||
return try JSONDecoder().decode([SharedItem].self, from: data)
|
||||
}
|
||||
|
||||
private static func writeManifest(_ items: [SharedItem], in root: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory(in: root), withIntermediateDirectories: true)
|
||||
try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic)
|
||||
}
|
||||
|
||||
/// The extension and the app are separate processes that can both touch the
|
||||
/// manifest, so read-modify-write goes through a file coordinator.
|
||||
private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory(in: root), withIntermediateDirectories: true)
|
||||
var coordinationError: NSError?
|
||||
var thrown: Error?
|
||||
NSFileCoordinator().coordinate(
|
||||
writingItemAt: directory(in: root), options: [], error: &coordinationError
|
||||
) { _ in
|
||||
do { try body() } catch { thrown = error }
|
||||
}
|
||||
if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/// The local notifications for an upload/download batch - one "Uploading 3
|
||||
/// files…" when it starts, replaced in place by "Uploaded 3 files" when it
|
||||
/// ends (both use the batch's id as the request identifier, so the second
|
||||
/// *replaces* the first instead of stacking). Posted from whichever process
|
||||
/// sees the event - the app, or the Share Extension while it's still alive.
|
||||
/// iOS can't update a notification with live byte progress from a background
|
||||
/// session, so start/finish is as much as a notification can say.
|
||||
/// Compiled into both targets.
|
||||
enum TransferNotifications {
|
||||
static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) {
|
||||
let verb = kind == .upload ? "Uploading" : "Downloading"
|
||||
post(id: batch, body: "\(verb) \(files(count))…", sound: false)
|
||||
}
|
||||
|
||||
static func finished(_ batch: TransferBatch, id: String) {
|
||||
let verb = batch.kind == .upload ? "upload" : "download"
|
||||
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
|
||||
let body: String
|
||||
if batch.failed == 0 {
|
||||
body = "\(past) \(files(batch.succeeded))"
|
||||
} else if batch.succeeded == 0 {
|
||||
body = "Couldn't \(verb) \(files(batch.failed))"
|
||||
} else {
|
||||
body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed"
|
||||
}
|
||||
post(id: id, body: body, sound: true)
|
||||
}
|
||||
|
||||
static func files(_ count: Int) -> String {
|
||||
count == 1 ? "1 file" : "\(count) files"
|
||||
}
|
||||
|
||||
private static func post(id: String, body: String, sound: Bool) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Noo"
|
||||
content.body = body
|
||||
if sound { content.sound = .default }
|
||||
UNUserNotificationCenter.current().add(
|
||||
UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/// Counting one finished upload task - shared so the result is handled the
|
||||
/// same way whether the Share Extension (still alive) or the app (relaunched
|
||||
/// later) receives it.
|
||||
enum UploadResults {
|
||||
/// Deletes the task's staged copy, records its outcome, and - if it was the
|
||||
/// last file of its batch - posts the summary notification and returns the
|
||||
/// finished batch.
|
||||
static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? {
|
||||
guard let info = task.taskDescription
|
||||
.flatMap({ $0.data(using: .utf8) })
|
||||
.flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }),
|
||||
info.kind == .upload
|
||||
else { return nil }
|
||||
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
|
||||
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let ok = error == nil && (200..<300).contains(status)
|
||||
let batch = TransferBatchStore.record(info, success: ok)
|
||||
if let batch { TransferNotifications.finished(batch, id: info.batch) }
|
||||
return (info, batch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate for the Share Extension's upload session while the extension is
|
||||
/// still alive: files that finish before it closes are counted here, the
|
||||
/// rest by the app (`TransferManager`) once the system relaunches it.
|
||||
final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let result = UploadResults.handle(task, error: error) else { return }
|
||||
// The app wasn't running to hear about this, so leave a note for it to
|
||||
// refresh that folder next time it's active.
|
||||
if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||
TransferBatchStore.noteFinishedUpload(folder: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// What a background upload/download task is, stored in the task's
|
||||
/// `taskDescription` so it's still known if the app was relaunched by the
|
||||
/// system to deliver the result - possibly for a task the Share Extension
|
||||
/// started. Compiled into both targets.
|
||||
struct TransferTaskInfo: Codable {
|
||||
enum Kind: String, Codable { case upload, download }
|
||||
|
||||
let kind: Kind
|
||||
let batch: String
|
||||
let name: String
|
||||
let remoteFolder: String?
|
||||
/// A file to delete once the task ends (the upload's staged copy).
|
||||
let stagedPath: String?
|
||||
}
|
||||
|
||||
/// Running totals for one "upload these 3 files" / "download these 2 files"
|
||||
/// request, so the one summary notification (and the Dart-side "folder
|
||||
/// changed" event) fires exactly once, on the last file.
|
||||
struct TransferBatch: Codable {
|
||||
let kind: TransferTaskInfo.Kind
|
||||
let remoteFolder: String?
|
||||
let total: Int
|
||||
var succeeded: Int
|
||||
var failed: Int
|
||||
|
||||
var finished: Int { succeeded + failed }
|
||||
}
|
||||
|
||||
/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the
|
||||
/// process's own: the Share Extension starts an upload batch, and the app -
|
||||
/// a different process, possibly relaunched in the background - finishes
|
||||
/// counting it.
|
||||
enum TransferBatchStore {
|
||||
private static let lock = NSLock()
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: AppGroup.identifier) ?? .standard
|
||||
}
|
||||
|
||||
private static func key(_ id: String) -> String { "transfer.batch.\(id)" }
|
||||
|
||||
static func save(_ batch: TransferBatch, id: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
|
||||
}
|
||||
|
||||
private static let finishedFoldersKey = "transfer.finishedUploadFolders"
|
||||
|
||||
/// The Share Extension finished an upload into [folder] while the app
|
||||
/// wasn't running; the app reads this on its next activation so its file
|
||||
/// list can refresh.
|
||||
static func noteFinishedUpload(folder: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||
if !folders.contains(folder) { folders.append(folder) }
|
||||
defaults.set(folders, forKey: finishedFoldersKey)
|
||||
}
|
||||
|
||||
static func consumeFinishedUploadFolders() -> [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||
defaults.removeObject(forKey: finishedFoldersKey)
|
||||
return folders
|
||||
}
|
||||
|
||||
/// Counts one file's outcome. Returns the batch when that was its last
|
||||
/// file (and forgets it), nil while others are still running.
|
||||
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let storageKey = key(info.batch)
|
||||
var batch =
|
||||
defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }
|
||||
?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0)
|
||||
if success { batch.succeeded += 1 } else { batch.failed += 1 }
|
||||
if batch.finished >= batch.total {
|
||||
defaults.removeObject(forKey: storageKey)
|
||||
return batch
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// URL building for a Nextcloud account's WebDAV files root. Pure functions
|
||||
/// (no networking), so they're unit-tested in `RunnerTests`.
|
||||
enum WebDAV {
|
||||
/// `urlPathAllowed` minus "/", so a file name containing a slash-like
|
||||
/// character can't introduce an extra path segment.
|
||||
private static let segmentAllowed: CharacterSet = {
|
||||
var set = CharacterSet.urlPathAllowed
|
||||
set.remove(charactersIn: "/")
|
||||
return set
|
||||
}()
|
||||
|
||||
static func encodeSegment(_ segment: String) -> String {
|
||||
segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment
|
||||
}
|
||||
|
||||
/// `<server>/remote.php/dav/files/<user>/<remotePath>` - [remotePath] is
|
||||
/// relative to the user's root, with or without a leading slash. A server
|
||||
/// installed under a sub-path (`https://host/nextcloud`) keeps it.
|
||||
static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? {
|
||||
var base = serverUrl
|
||||
while base.hasSuffix("/") { base.removeLast() }
|
||||
let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) }
|
||||
var url = base + "/remote.php/dav/files/" + encodeSegment(username)
|
||||
if !segments.isEmpty { url += "/" + segments.joined(separator: "/") }
|
||||
return URL(string: url)
|
||||
}
|
||||
|
||||
/// `folder` + `name` with exactly one slash between them.
|
||||
static func join(_ folder: String, _ name: String) -> String {
|
||||
folder.hasSuffix("/") ? folder + name : folder + "/" + name
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalFiles {
|
||||
/// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then
|
||||
/// "a (1).txt", "a (2).txt"... - so a second download of the same file
|
||||
/// never overwrites the first.
|
||||
static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL {
|
||||
let candidate = directory.appendingPathComponent(name)
|
||||
if !fileManager.fileExists(atPath: candidate.path) { return candidate }
|
||||
let ext = (name as NSString).pathExtension
|
||||
let stem = (name as NSString).deletingPathExtension
|
||||
var index = 1
|
||||
while true {
|
||||
let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)"
|
||||
let url = directory.appendingPathComponent(numbered)
|
||||
if !fileManager.fileExists(atPath: url.path) { return url }
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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_account_service.dart';
|
||||
import 'services/share_intent_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/design_tokens.dart';
|
||||
@@ -31,12 +32,13 @@ import 'views/share_upload_view.dart';
|
||||
import 'widgets/app_drawer.dart';
|
||||
import 'widgets/app_tab_view_builder.dart';
|
||||
import 'widgets/app_top_bar.dart';
|
||||
import 'widgets/avatar_menu.dart';
|
||||
import 'widgets/bottom_nav_bar.dart';
|
||||
import 'widgets/create_menu.dart';
|
||||
import 'widgets/noo/core/noo_avatar.dart';
|
||||
import 'widgets/noo/core/noo_button.dart';
|
||||
import 'widgets/noo/core/noo_fab.dart';
|
||||
import 'widgets/noo/nav/noo_nav_style.dart';
|
||||
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||
import 'widgets/noo/noo_layout.dart';
|
||||
@@ -58,7 +60,14 @@ void main() {
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => SessionController(context.read()),
|
||||
create: (context) {
|
||||
final session = SessionController(context.read());
|
||||
// iOS's Share Extension has no Flutter engine; sign-out must wipe
|
||||
// what it was given. (Publishing happens in the shell - see
|
||||
// ShareAccountSync - once there's a FilesController to read.)
|
||||
session.addAccountClearedListener(ShareAccountService.clear);
|
||||
return session;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||
// Before SyncStatusController, which follows its Files Cache rule
|
||||
@@ -202,9 +211,11 @@ class MainShellView extends StatefulWidget {
|
||||
|
||||
class _MainShellViewState extends State<MainShellView> {
|
||||
late AppTab _currentTab;
|
||||
final Set<AppTab> _selectionActiveTabs = {};
|
||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||
StreamSubscription<PickRequest>? _pickSub;
|
||||
ShareAccountSync? _shareAccountSync;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -226,6 +237,13 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
||||
|
||||
// iOS's Share Extension picks accounts/folders on its own, so it's kept
|
||||
// supplied with the accounts, lock settings and hidden-files filters.
|
||||
_shareAccountSync = ShareAccountSync(
|
||||
context.read<SessionController>(),
|
||||
context.read<FilesController>(),
|
||||
)..start();
|
||||
|
||||
// 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) {
|
||||
@@ -310,6 +328,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
}
|
||||
_shareSub?.cancel();
|
||||
_pickSub?.cancel();
|
||||
_shareAccountSync?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -413,24 +432,41 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
// null`).
|
||||
final tabStack = Stack(
|
||||
children: [
|
||||
IndexedStack(
|
||||
index: selectedIndex,
|
||||
children: displayTabs
|
||||
.map(
|
||||
(tab) => buildAppTabView(
|
||||
tab,
|
||||
_scrollControllers[tab]!,
|
||||
topBar: isDesktop || pickRequest != null
|
||||
? null
|
||||
: AppTopBar(
|
||||
style: navStyle,
|
||||
tab: tab,
|
||||
searchInBottomBar: showBottomBarSearch,
|
||||
navMenuStyle: settings.navMenuStyle,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
AvatarNavigationBody(
|
||||
child: IndexedStack(
|
||||
index: selectedIndex,
|
||||
children: displayTabs
|
||||
.map(
|
||||
(tab) => buildAppTabView(
|
||||
tab,
|
||||
_scrollControllers[tab]!,
|
||||
onSelectionChanged: (selecting) {
|
||||
if (!mounted ||
|
||||
selecting == _selectionActiveTabs.contains(tab)) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (selecting) {
|
||||
_selectionActiveTabs.add(tab);
|
||||
} else {
|
||||
_selectionActiveTabs.remove(tab);
|
||||
}
|
||||
});
|
||||
},
|
||||
topBar: isDesktop || pickRequest != null
|
||||
? null
|
||||
: AppTopBar(
|
||||
style: navStyle,
|
||||
tab: tab,
|
||||
searchInBottomBar: showBottomBarSearch,
|
||||
navMenuStyle: settings.navMenuStyle,
|
||||
avatarPosition: settings.avatarPosition,
|
||||
uploadButtonStyle: settings.fabStyle,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||
],
|
||||
@@ -526,15 +562,21 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||
// Floating needs the body to draw behind the bar's own transparent
|
||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||
// short of it like attached does.
|
||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
||||
// short of it like attached does; a frosted bar needs it too, or
|
||||
// there'd be nothing behind it to blur.
|
||||
extendBody: NooBottomBar.drawsBehindBody(
|
||||
bottomBarStyle,
|
||||
settings.bottomBarFrosted,
|
||||
),
|
||||
// Android-only extended Upload FAB - iOS uses the top bar's Upload
|
||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||
// rather than the Scaffold popping it fully in/out on every tab
|
||||
// switch - see NooFab's [collapsed].
|
||||
floatingActionButton:
|
||||
pickRequest == null && navStyle == NooNavStyle.android
|
||||
pickRequest == null &&
|
||||
navStyle == NooNavStyle.android &&
|
||||
!_selectionActiveTabs.contains(selectedTab)
|
||||
? NooFab(
|
||||
collapsed: switch (settings.fabStyle) {
|
||||
FabStyle.auto => !canUpload,
|
||||
@@ -545,13 +587,35 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
onTap: () => showCreateMenu(context),
|
||||
)
|
||||
: null,
|
||||
body: tabStack,
|
||||
body: TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
end: switch (settings.fabStyle) {
|
||||
FabStyle.auto => canUpload ? 1.0 : 0.0,
|
||||
FabStyle.mini => 0.0,
|
||||
FabStyle.expanded => 1.0,
|
||||
},
|
||||
),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: NooMotion.base,
|
||||
curve: NooMotion.ease,
|
||||
child: tabStack,
|
||||
builder: (context, progress, child) =>
|
||||
UploadButtonAnimation(progress: progress, child: child!),
|
||||
),
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
style: navStyle,
|
||||
barStyle: bottomBarStyle,
|
||||
frosted: settings.bottomBarFrosted,
|
||||
frostedBlur: settings.bottomBarFrostedBlur,
|
||||
frostedOpacity: settings.bottomBarFrostedOpacity,
|
||||
tabs: pinnedTabs,
|
||||
selectedIndex: pinnedIndex,
|
||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||
avatarInBottomBar:
|
||||
pickRequest == null &&
|
||||
settings.navMenuStyle == NooNavMenuStyle.avatarMenu &&
|
||||
settings.avatarPosition == AvatarPosition.bottom,
|
||||
onDestinationSelected: (index) {
|
||||
final tappedTab = pinnedTabs[index];
|
||||
if (tappedTab == _currentTab) {
|
||||
@@ -591,7 +655,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && pickRequest != null) pick.cancelPick();
|
||||
},
|
||||
child: scaffold,
|
||||
child: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ 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 }
|
||||
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||
/// external items in their own collapsible section then). Shared across
|
||||
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external, all }
|
||||
|
||||
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
|
||||
/// default), show [only] them, or [include] them alongside everything else.
|
||||
/// Files and Photos each keep their own (see [applyCommonFilters]).
|
||||
enum HiddenFilesFilter { hide, only, include }
|
||||
|
||||
/// The Files tab's files/folders/both filter — independent of and applied
|
||||
/// after [StorageScope]/favorites/hidden filtering.
|
||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
||||
static const _prefStorageScope = 'ui_storage_scope';
|
||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||
static const _prefHiddenFilter = 'ui_hidden_filter';
|
||||
static const _prefFolderSort = 'ui_folder_sort';
|
||||
static const prefCachePolicy = 'ui_cache_policy';
|
||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
||||
bool _isGridView = false;
|
||||
StorageScope _storageScope = StorageScope.cloud;
|
||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||
bool _showHiddenFiles = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
|
||||
final Map<String, FileSortField> _folderSortField = {};
|
||||
final Map<String, bool> _folderSortAscending = {};
|
||||
@@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier
|
||||
bool get isGridView => _isGridView;
|
||||
StorageScope get storageScope => _storageScope;
|
||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||
bool get showHiddenFiles => _showHiddenFiles;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||
|
||||
@@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier
|
||||
List<NextcloudItem> applyCommonFilters(
|
||||
List<NextcloudItem> source, {
|
||||
required bool showFavoritesOnly,
|
||||
required bool showHidden,
|
||||
required HiddenFilesFilter hidden,
|
||||
bool applyStorageScope = true,
|
||||
}) {
|
||||
var filtered = source;
|
||||
if (showFavoritesOnly) {
|
||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||
}
|
||||
if (!showHidden) {
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
switch (hidden) {
|
||||
case HiddenFilesFilter.hide:
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
case HiddenFilesFilter.only:
|
||||
filtered = filtered.where(_isHiddenItem).toList();
|
||||
case HiddenFilesFilter.include:
|
||||
break;
|
||||
}
|
||||
if (applyStorageScope) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(item) => _storageScope == StorageScope.external
|
||||
? item.isExternalStorage
|
||||
: !item.isExternalStorage,
|
||||
)
|
||||
.toList();
|
||||
switch (_storageScope) {
|
||||
case StorageScope.cloud:
|
||||
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||
case StorageScope.external:
|
||||
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||
case StorageScope.all:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier
|
||||
// Files itself doesn't filter by favorite - that's the dedicated
|
||||
// Favorites tab's job.
|
||||
showFavoritesOnly: false,
|
||||
showHidden: _showHiddenFiles,
|
||||
hidden: _hiddenFilter,
|
||||
applyStorageScope: applyStorageScope,
|
||||
);
|
||||
switch (_filesTypeFilter) {
|
||||
@@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier
|
||||
_isGridView = false;
|
||||
_storageScope = StorageScope.cloud;
|
||||
_filesTypeFilter = FilesTypeFilter.all;
|
||||
_showHiddenFiles = false;
|
||||
_hiddenFilter = HiddenFilesFilter.hide;
|
||||
_cachePolicy = defaultCachePolicy;
|
||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||
_isLoading = false;
|
||||
@@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier
|
||||
(f) => f.name == filesTypeFilterName,
|
||||
orElse: () => FilesTypeFilter.all,
|
||||
);
|
||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||
_hiddenFilter = loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilter),
|
||||
k(_prefShowHiddenFiles),
|
||||
);
|
||||
|
||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||
if (folderSortJson != null) {
|
||||
@@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier
|
||||
);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||
/// never exposes anything, so that direction is always allowed instantly
|
||||
/// (and switching between the two revealing modes needs no second prompt).
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -650,14 +669,61 @@ class FilesController extends ChangeNotifier
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenFiles,
|
||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
||||
_prefHiddenFilter,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// The storage scope saved for [accountId] (see [savedHiddenFilter]).
|
||||
static StorageScope savedStorageScope(
|
||||
SharedPreferences prefs,
|
||||
String Function(String accountId, String baseKey) accountPrefKey,
|
||||
String accountId,
|
||||
) {
|
||||
final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope));
|
||||
return StorageScope.values.firstWhere(
|
||||
(s) => s.name == name,
|
||||
orElse: () => StorageScope.cloud,
|
||||
);
|
||||
}
|
||||
|
||||
/// The hidden-files filter saved for [accountId] - not just the active
|
||||
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
|
||||
/// which follows each account's own setting.
|
||||
static HiddenFilesFilter savedHiddenFilter(
|
||||
SharedPreferences prefs,
|
||||
String Function(String accountId, String baseKey) accountPrefKey,
|
||||
String accountId,
|
||||
) => loadHiddenFilter(
|
||||
prefs,
|
||||
accountPrefKey(accountId, _prefHiddenFilter),
|
||||
accountPrefKey(accountId, _prefShowHiddenFiles),
|
||||
);
|
||||
|
||||
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||
/// already account-namespaced by the caller.
|
||||
static HiddenFilesFilter loadHiddenFilter(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
String legacyBoolKey,
|
||||
) {
|
||||
final name = prefs.getString(key);
|
||||
if (name != null) {
|
||||
return HiddenFilesFilter.values.firstWhere(
|
||||
(f) => f.name == name,
|
||||
orElse: () => HiddenFilesFilter.hide,
|
||||
);
|
||||
}
|
||||
return (prefs.getBool(legacyBoolKey) ?? false)
|
||||
? HiddenFilesFilter.include
|
||||
: HiddenFilesFilter.hide;
|
||||
}
|
||||
|
||||
void _persistFolderSort() {
|
||||
final combined = <String, dynamic>{};
|
||||
for (final path in {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import 'files_controller.dart';
|
||||
@@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
|
||||
_syncBasePath ??= (await SyncService.baseDirectory())?.path;
|
||||
// A sync can delete the very folder being browsed (deleted on the
|
||||
// server) - step out to the nearest ancestor that still exists rather
|
||||
// than showing a phantom empty folder.
|
||||
@@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier
|
||||
Future<String?> localPathFor(NextcloudItem item) async {
|
||||
final accountId = session.activeAccountId;
|
||||
if (accountId == null) return null;
|
||||
final base = await getExternalStorageDirectory();
|
||||
final base = await SyncService.baseDirectory();
|
||||
if (base == null) return null;
|
||||
final relPath = item.path.startsWith('/')
|
||||
? item.path.substring(1)
|
||||
@@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier
|
||||
String accountId,
|
||||
String folderPath,
|
||||
) async {
|
||||
final base = await getExternalStorageDirectory();
|
||||
final base = await SyncService.baseDirectory();
|
||||
if (base == null) return [];
|
||||
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
||||
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
||||
|
||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
||||
|
||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||
static const _prefSortField = 'ui_sort_field';
|
||||
static const _prefSortAscending = 'ui_sort_ascending';
|
||||
|
||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
|
||||
bool _showFavoritesOnly = false;
|
||||
bool _showHidden = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
FileSortField _sortField = FileSortField.name;
|
||||
bool _sortAscending = true;
|
||||
|
||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||
bool get showHidden => _showHidden;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get sortField => _sortField;
|
||||
bool get sortAscending => _sortAscending;
|
||||
|
||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
||||
final filtered = files.applyCommonFilters(
|
||||
media,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
showHidden: _showHidden,
|
||||
hidden: _hiddenFilter,
|
||||
)..sort((a, b) => _compare(a, b));
|
||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||
}
|
||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
_showFavoritesOnly =
|
||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilterPhotos),
|
||||
k(_prefShowHiddenPhotos),
|
||||
);
|
||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||
_sortField = FileSortField.values.firstWhere(
|
||||
(f) => f.name == sortFieldName,
|
||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleShowHidden() async {
|
||||
if (!_showHidden) {
|
||||
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHidden = !_showHidden;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenPhotos,
|
||||
(p, key) => p.setBool(key, _showHidden),
|
||||
_prefHiddenFilterPhotos,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
/// 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
|
||||
/// behind its own unlock 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.
|
||||
@@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
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.
|
||||
/// Turns login lock off. Requires a successful auth first, same as turning
|
||||
/// it on - otherwise anyone with momentary access to an unlocked phone
|
||||
/// could just switch it off. Only this lock: the account-switching and
|
||||
/// hidden-files locks are independent and stay as they are.
|
||||
Future<bool> disableLoginLock() async {
|
||||
if (!_loginLockEnabled) return true;
|
||||
final confirmed = await AppLockService.authenticate(
|
||||
@@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
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);
|
||||
});
|
||||
prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false));
|
||||
return true;
|
||||
}
|
||||
|
||||
void setLockAccountSwitching(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
/// Turns the "unlock to switch accounts" gate on or off. Independent of
|
||||
/// login lock. Both directions need a successful auth - enabling proves the
|
||||
/// device can authenticate at all (a gate nobody can pass would lock the
|
||||
/// user out of switching), disabling stops anyone with momentary access to
|
||||
/// an unlocked phone from just removing it. Returns whether it changed.
|
||||
Future<bool> setLockAccountSwitching(bool value) async {
|
||||
if (value == _lockAccountSwitching) return true;
|
||||
if (!await _confirmLockChange(
|
||||
value,
|
||||
'Confirm to lock account switching',
|
||||
'Confirm to unlock account switching',
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_lockAccountSwitching = value;
|
||||
notifyListeners();
|
||||
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||
return true;
|
||||
}
|
||||
|
||||
void setLockHiddenFiles(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
/// Same as [setLockAccountSwitching], for revealing hidden files.
|
||||
Future<bool> setLockHiddenFiles(bool value) async {
|
||||
if (value == _lockHiddenFiles) return true;
|
||||
if (!await _confirmLockChange(
|
||||
value,
|
||||
'Confirm to lock hidden files',
|
||||
'Confirm to unlock hidden files',
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_lockHiddenFiles = value;
|
||||
notifyListeners();
|
||||
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _confirmLockChange(
|
||||
bool enabling,
|
||||
String enableReason,
|
||||
String disableReason,
|
||||
) async {
|
||||
if (enabling && !await AppLockService.isDeviceSupported()) return false;
|
||||
return AppLockService.authenticate(enabling ? enableReason : disableReason);
|
||||
}
|
||||
|
||||
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||
@@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
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.
|
||||
/// Prompts for auth if [gate] is on; returns true immediately (no prompt)
|
||||
/// otherwise. Each gate stands on its own - it doesn't depend on login lock
|
||||
/// being on. 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;
|
||||
if (!gate) return true;
|
||||
return AppLockService.authenticate(reason);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,16 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
||||
/// in Settings.
|
||||
enum SwipeAction { none, favorite, delete, share }
|
||||
|
||||
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
|
||||
/// How the mobile Upload button is sized: [auto] expands on Files/Photos and
|
||||
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
||||
/// always shows the label.
|
||||
enum FabStyle { auto, mini, expanded }
|
||||
|
||||
/// Where bulk selection actions float within the content pane.
|
||||
enum SelectionBarPosition { top, bottom }
|
||||
|
||||
enum AvatarPosition { top, bottom }
|
||||
|
||||
/// 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
|
||||
@@ -21,6 +26,30 @@ enum FabStyle { auto, mini, expanded }
|
||||
/// animated wave with a tick-mark thumb ([squiggly]).
|
||||
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
||||
|
||||
/// The frosted-glass bottom bar's blur sigma and surface opacity, in three
|
||||
/// ready-made strengths (Settings, Appearance). Moving either slider off all
|
||||
/// three leaves no preset selected (a custom mix).
|
||||
enum FrostedGlassPreset {
|
||||
less(blur: 10, opacity: 0.88, label: 'Less'),
|
||||
standard(blur: 20, opacity: 0.72, label: 'Default'),
|
||||
more(blur: 32, opacity: 0.55, label: 'More');
|
||||
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final String label;
|
||||
|
||||
const FrostedGlassPreset({
|
||||
required this.blur,
|
||||
required this.opacity,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
const double minFrostedBlur = 0;
|
||||
const double maxFrostedBlur = 40;
|
||||
const double minFrostedOpacity = 0.3;
|
||||
const double maxFrostedOpacity = 1.0;
|
||||
|
||||
/// 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
|
||||
@@ -31,6 +60,7 @@ class SettingsController extends ChangeNotifier {
|
||||
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
|
||||
static const _prefSeedColor = 'ui_seed_color';
|
||||
static const _prefTabOrder = 'ui_tab_order';
|
||||
static const _prefSelectionBarPosition = 'ui_selection_bar_position';
|
||||
static const _prefSelectionActionOrder = 'ui_selection_action_order';
|
||||
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
||||
static const _prefDefaultTab = 'ui_default_tab';
|
||||
@@ -38,6 +68,10 @@ class SettingsController extends ChangeNotifier {
|
||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||
static const _prefFabStyle = 'ui_fab_style';
|
||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
||||
static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur';
|
||||
static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity';
|
||||
static const _prefAvatarPosition = 'ui_avatar_position';
|
||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||
@@ -51,12 +85,17 @@ class SettingsController extends ChangeNotifier {
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||
AvatarPosition _avatarPosition = AvatarPosition.top;
|
||||
bool _bottomBarFrosted = false;
|
||||
double _frostedBlur = FrostedGlassPreset.standard.blur;
|
||||
double _frostedOpacity = FrostedGlassPreset.standard.opacity;
|
||||
bool _searchInBottomBar = false;
|
||||
bool _useDynamicColor = true;
|
||||
bool _amoledDark = false;
|
||||
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
||||
|
||||
bool _tapTabToScrollTop = true;
|
||||
SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top;
|
||||
|
||||
List<AppTab> _tabOrder = AppTab.values.toList();
|
||||
Set<AppTab> _hiddenTabs = {};
|
||||
@@ -95,12 +134,23 @@ class SettingsController extends ChangeNotifier {
|
||||
Color get seedColor => _seedColor;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||
double get bottomBarFrostedBlur => _frostedBlur;
|
||||
double get bottomBarFrostedOpacity => _frostedOpacity;
|
||||
|
||||
/// The preset the current blur/opacity match exactly, or null when the
|
||||
/// sliders have been moved to something custom.
|
||||
FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values
|
||||
.where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity)
|
||||
.firstOrNull;
|
||||
AvatarPosition get avatarPosition => _avatarPosition;
|
||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||
bool get searchInBottomBar => _searchInBottomBar;
|
||||
bool get useDynamicColor => _useDynamicColor;
|
||||
bool get amoledDark => _amoledDark;
|
||||
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||
SelectionBarPosition get selectionBarPosition => _selectionBarPosition;
|
||||
|
||||
/// The cap a screen should actually enforce for the *regular*, user-
|
||||
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
||||
@@ -141,6 +191,11 @@ class SettingsController extends ChangeNotifier {
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
}
|
||||
_selectionBarPosition = SelectionBarPosition.values.firstWhere(
|
||||
(position) =>
|
||||
position.name == prefs.getString(_prefSelectionBarPosition),
|
||||
orElse: () => SelectionBarPosition.top,
|
||||
);
|
||||
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
||||
if (bottomBarStyleName != null) {
|
||||
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
||||
@@ -148,6 +203,10 @@ class SettingsController extends ChangeNotifier {
|
||||
orElse: () => _bottomBarStyle,
|
||||
);
|
||||
}
|
||||
_avatarPosition = AvatarPosition.values.firstWhere(
|
||||
(position) => position.name == prefs.getString(_prefAvatarPosition),
|
||||
orElse: () => AvatarPosition.top,
|
||||
);
|
||||
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
||||
if (navMenuStyleName != null) {
|
||||
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
||||
@@ -155,6 +214,17 @@ class SettingsController extends ChangeNotifier {
|
||||
orElse: () => _navMenuStyle,
|
||||
);
|
||||
}
|
||||
_bottomBarFrosted =
|
||||
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||
_frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp(
|
||||
minFrostedBlur,
|
||||
maxFrostedBlur,
|
||||
);
|
||||
_frostedOpacity =
|
||||
(prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp(
|
||||
minFrostedOpacity,
|
||||
maxFrostedOpacity,
|
||||
);
|
||||
_searchInBottomBar =
|
||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||
_useDynamicColor =
|
||||
@@ -306,6 +376,22 @@ class SettingsController extends ChangeNotifier {
|
||||
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
||||
}
|
||||
|
||||
void setAvatarPosition(AvatarPosition position) {
|
||||
if (_avatarPosition == position) return;
|
||||
_avatarPosition = position;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setString(_prefAvatarPosition, position.name));
|
||||
}
|
||||
|
||||
void setSelectionBarPosition(SelectionBarPosition position) {
|
||||
if (_selectionBarPosition == position) return;
|
||||
_selectionBarPosition = position;
|
||||
notifyListeners();
|
||||
_prefsFuture.then(
|
||||
(p) => p.setString(_prefSelectionBarPosition, position.name),
|
||||
);
|
||||
}
|
||||
|
||||
void setBottomBarStyle(NooBottomBarStyle style) {
|
||||
if (_bottomBarStyle == style) return;
|
||||
_bottomBarStyle = style;
|
||||
@@ -313,6 +399,34 @@ class SettingsController extends ChangeNotifier {
|
||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||
}
|
||||
|
||||
void setFrostedBlur(double value) {
|
||||
final v = value.clamp(minFrostedBlur, maxFrostedBlur);
|
||||
if (_frostedBlur == v) return;
|
||||
_frostedBlur = v;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setDouble(_prefFrostedBlur, v));
|
||||
}
|
||||
|
||||
void setFrostedOpacity(double value) {
|
||||
final v = value.clamp(minFrostedOpacity, maxFrostedOpacity);
|
||||
if (_frostedOpacity == v) return;
|
||||
_frostedOpacity = v;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setDouble(_prefFrostedOpacity, v));
|
||||
}
|
||||
|
||||
void setFrostedPreset(FrostedGlassPreset preset) {
|
||||
setFrostedBlur(preset.blur);
|
||||
setFrostedOpacity(preset.opacity);
|
||||
}
|
||||
|
||||
void setBottomBarFrosted(bool value) {
|
||||
if (_bottomBarFrosted == value) return;
|
||||
_bottomBarFrosted = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
|
||||
}
|
||||
|
||||
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||
if (_navMenuStyle == style) return;
|
||||
_navMenuStyle = style;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||
@@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart';
|
||||
class AppLockService {
|
||||
static final LocalAuthentication _auth = LocalAuthentication();
|
||||
|
||||
/// Replace the platform prompt in tests (null = the real one).
|
||||
@visibleForTesting
|
||||
static Future<bool> Function(String reason)? debugAuthenticate;
|
||||
|
||||
@visibleForTesting
|
||||
static Future<bool> Function()? debugIsDeviceSupported;
|
||||
|
||||
/// Whether this device can do *some* form of local auth - biometric
|
||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||
static Future<bool> isDeviceSupported() async {
|
||||
if (debugIsDeviceSupported != null) return debugIsDeviceSupported!();
|
||||
try {
|
||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||
if (canCheckBiometrics) return true;
|
||||
@@ -24,6 +33,7 @@ class AppLockService {
|
||||
/// throws) on cancellation, failure, or any platform error, so callers
|
||||
/// can treat every non-true result the same way: stay locked/blocked.
|
||||
static Future<bool> authenticate(String reason) async {
|
||||
if (debugAuthenticate != null) return debugAuthenticate!(reason);
|
||||
try {
|
||||
return await _auth.authenticate(
|
||||
localizedReason: reason,
|
||||
|
||||
@@ -38,6 +38,9 @@ class DownloadService {
|
||||
.toList(),
|
||||
);
|
||||
|
||||
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
|
||||
await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
|
||||
...args,
|
||||
'files': filesJson,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../providers/session_controller.dart';
|
||||
|
||||
/// The "get this account's auth header, or bail" guard every native-
|
||||
@@ -21,3 +22,57 @@ Map<String, String> baseChannelArgs(SessionController session) {
|
||||
'authHeader': requireAuthHeader(session),
|
||||
};
|
||||
}
|
||||
|
||||
/// Thrown by user-triggered native services (upload, download, sync now...)
|
||||
/// on a platform whose native side isn't implemented yet - the message is
|
||||
/// shown to the user as-is, e.g. in a snackbar.
|
||||
class NativeServiceUnavailable implements Exception {
|
||||
final String feature;
|
||||
|
||||
const NativeServiceUnavailable(this.feature);
|
||||
|
||||
@override
|
||||
String toString() => "$feature isn't available on this platform yet.";
|
||||
}
|
||||
|
||||
/// Calls [method], treating a missing native implementation (no
|
||||
/// `MethodChannel` handler registered, as on iOS until each service is
|
||||
/// built there) as "nothing to report": returns null instead of throwing
|
||||
/// `MissingPluginException`. For cold-start checks, scheduling and other
|
||||
/// calls nobody is waiting on.
|
||||
Future<T?> invokeIfAvailable<T>(
|
||||
MethodChannel channel,
|
||||
String method, [
|
||||
Object? arguments,
|
||||
]) async {
|
||||
try {
|
||||
return await channel.invokeMethod<T>(method, arguments);
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [invokeIfAvailable], but for an action the user just asked for:
|
||||
/// throws [NativeServiceUnavailable] naming [feature] rather than silently
|
||||
/// doing nothing.
|
||||
Future<T?> invokeOrExplain<T>(
|
||||
MethodChannel channel,
|
||||
String method,
|
||||
String feature, [
|
||||
Object? arguments,
|
||||
]) async {
|
||||
try {
|
||||
return await channel.invokeMethod<T>(method, arguments);
|
||||
} on MissingPluginException {
|
||||
throw NativeServiceUnavailable(feature);
|
||||
}
|
||||
}
|
||||
|
||||
/// [channel]'s broadcast stream mapped through [convert], with the
|
||||
/// "no native implementation" error dropped - the stream just never emits.
|
||||
Stream<T> quietEvents<T>(EventChannel channel, T Function(dynamic) convert) {
|
||||
return channel
|
||||
.receiveBroadcastStream()
|
||||
.handleError((Object _) {}, test: (e) => e is MissingPluginException)
|
||||
.map(convert);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/pick_request.dart';
|
||||
import 'native_channel.dart';
|
||||
|
||||
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
||||
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
||||
@@ -14,7 +15,8 @@ class PickIntentService {
|
||||
/// 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>>(
|
||||
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||
_methodChannel,
|
||||
'getPickRequest',
|
||||
);
|
||||
return result == null ? null : PickRequest.fromMap(result);
|
||||
@@ -23,7 +25,8 @@ class PickIntentService {
|
||||
/// Emits whenever the app is asked to act as a picker while already
|
||||
/// running.
|
||||
static Stream<PickRequest> get onNewPickRequest {
|
||||
return _newPickChannel.receiveBroadcastStream().map(
|
||||
return quietEvents(
|
||||
_newPickChannel,
|
||||
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
}
|
||||
@@ -34,7 +37,7 @@ class PickIntentService {
|
||||
List<String> localPaths,
|
||||
List<String> mimeTypes,
|
||||
) {
|
||||
return _methodChannel.invokeMethod('finishPick', {
|
||||
return invokeIfAvailable(_methodChannel, 'finishPick', {
|
||||
'paths': localPaths,
|
||||
'mimeTypes': mimeTypes,
|
||||
});
|
||||
@@ -42,6 +45,6 @@ class PickIntentService {
|
||||
|
||||
/// Backs out of picking mode with no result, closing the picker.
|
||||
static Future<void> cancelPick() {
|
||||
return _methodChannel.invokeMethod('cancelPick');
|
||||
return invokeIfAvailable(_methodChannel, 'cancelPick');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../providers/session_controller.dart';
|
||||
import 'native_channel.dart';
|
||||
|
||||
/// One account as the iOS Share Extension needs it - plain data, so the
|
||||
/// payload builder is testable without a session.
|
||||
class ShareAccountEntry {
|
||||
final String id;
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
|
||||
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
|
||||
final String hiddenFilter;
|
||||
|
||||
/// The account's Files storage scope: `cloud`, `external` or `all`.
|
||||
final String storageScope;
|
||||
|
||||
const ShareAccountEntry({
|
||||
required this.id,
|
||||
required this.serverUrl,
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.hiddenFilter,
|
||||
required this.storageScope,
|
||||
});
|
||||
}
|
||||
|
||||
/// Tells the native side which accounts can upload, which one is active, and
|
||||
/// the app-lock settings, so iOS's Share Extension - a separate process with
|
||||
/// no Flutter engine - can offer an account picker, list folders and upload
|
||||
/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s
|
||||
/// `share_account`, stored in a Keychain group shared with the extension).
|
||||
/// Android has no equivalent (its share intent opens the app itself), so there
|
||||
/// the channel simply doesn't exist and everything here is a no-op.
|
||||
class ShareAccountService {
|
||||
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
||||
|
||||
static String basicAuth(String username, String password) =>
|
||||
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||
|
||||
/// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes.
|
||||
static String buildPayload({
|
||||
required List<ShareAccountEntry> accounts,
|
||||
required String? activeId,
|
||||
required bool loginLockEnabled,
|
||||
required bool lockAccountSwitching,
|
||||
required bool lockHiddenFiles,
|
||||
}) {
|
||||
return jsonEncode({
|
||||
'accounts': [
|
||||
for (final a in accounts)
|
||||
{
|
||||
'id': a.id,
|
||||
'serverUrl': a.serverUrl,
|
||||
'username': a.username,
|
||||
'authHeader': basicAuth(a.username, a.password),
|
||||
'displayName':
|
||||
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
||||
'hiddenFilter': a.hiddenFilter,
|
||||
'storageScope': a.storageScope,
|
||||
},
|
||||
],
|
||||
'activeId': activeId,
|
||||
'loginLockEnabled': loginLockEnabled,
|
||||
'lockAccountSwitching': lockAccountSwitching,
|
||||
'lockHiddenFiles': lockHiddenFiles,
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes every saved account that can actually upload (has a stored
|
||||
/// password and isn't signed out), with the active one's hidden-files
|
||||
/// filter and storage scope taken live from [files] - the saved prefs are
|
||||
/// written asynchronously, so they can lag a change that just happened.
|
||||
static Future<void> publish(
|
||||
SessionController session,
|
||||
FilesController files,
|
||||
) async {
|
||||
final activeId = session.activeAccountId;
|
||||
if (activeId == null) return clear();
|
||||
|
||||
final prefs = await session.prefsFuture;
|
||||
final store = session.accountStore;
|
||||
final signedOut = store.loadSignedOut(prefs);
|
||||
final entries = <ShareAccountEntry>[];
|
||||
for (final account in session.accounts) {
|
||||
if (signedOut.contains(account.id)) {
|
||||
debugPrint('[ShareAccounts] skipping ${account.id}: signed out');
|
||||
continue;
|
||||
}
|
||||
final password = await store.readPassword(account.id);
|
||||
if (password == null) {
|
||||
debugPrint(
|
||||
'[ShareAccounts] skipping ${account.id}: no stored password',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final isActive = account.id == activeId;
|
||||
final filter = isActive
|
||||
? files.hiddenFilter
|
||||
: FilesController.savedHiddenFilter(
|
||||
prefs,
|
||||
store.accountPrefKey,
|
||||
account.id,
|
||||
);
|
||||
final scope = isActive
|
||||
? files.storageScope
|
||||
: FilesController.savedStorageScope(
|
||||
prefs,
|
||||
store.accountPrefKey,
|
||||
account.id,
|
||||
);
|
||||
entries.add(
|
||||
ShareAccountEntry(
|
||||
id: account.id,
|
||||
serverUrl: account.serverUrl,
|
||||
username: account.username,
|
||||
password: password,
|
||||
hiddenFilter: filter.name,
|
||||
storageScope: scope.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
debugPrint(
|
||||
'[ShareAccounts] publishing ${entries.length} of '
|
||||
'${session.accounts.length} saved accounts, active=$activeId '
|
||||
'(${signedOut.length} signed out)',
|
||||
);
|
||||
if (entries.isEmpty) return clear();
|
||||
|
||||
await invokeIfAvailable(_channel, 'setAccounts', {
|
||||
'accounts': buildPayload(
|
||||
accounts: entries,
|
||||
activeId: activeId,
|
||||
loginLockEnabled: session.loginLockEnabled,
|
||||
lockAccountSwitching: session.lockAccountSwitching,
|
||||
lockHiddenFiles: session.lockHiddenFiles,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/// Forgets everything (sign-out) so the extension stops offering uploads.
|
||||
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
|
||||
}
|
||||
|
||||
/// Keeps [ShareAccountService]'s published data current while the app is
|
||||
/// running: republishes when an account becomes ready or when anything the
|
||||
/// extension depends on changes - the account list or active account, the
|
||||
/// lock settings, or the active account's hidden-files filter.
|
||||
class ShareAccountSync {
|
||||
final SessionController session;
|
||||
final FilesController files;
|
||||
|
||||
String? _lastSignature;
|
||||
bool _disposed = false;
|
||||
Future<void> _queue = Future.value();
|
||||
|
||||
ShareAccountSync(this.session, this.files);
|
||||
|
||||
void start() {
|
||||
session.addAccountReadyListener(_publish);
|
||||
session.addListener(_onChange);
|
||||
files.addListener(_onChange);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
session.removeListener(_onChange);
|
||||
files.removeListener(_onChange);
|
||||
}
|
||||
|
||||
String _signature() => [
|
||||
session.activeAccountId,
|
||||
session.accounts.map((a) => a.id).join(','),
|
||||
session.loginLockEnabled,
|
||||
session.lockAccountSwitching,
|
||||
session.lockHiddenFiles,
|
||||
files.hiddenFilter.name,
|
||||
files.storageScope.name,
|
||||
].join('|');
|
||||
|
||||
void _onChange() {
|
||||
if (_signature() != _lastSignature) _publish();
|
||||
}
|
||||
|
||||
/// Runs one at a time, in order, so a slow publish can't land after a
|
||||
/// newer one and leave the extension with stale data.
|
||||
void _publish() {
|
||||
if (_disposed) return;
|
||||
_lastSignature = _signature();
|
||||
_queue = _queue.then((_) async {
|
||||
if (_disposed) return;
|
||||
try {
|
||||
await ShareAccountService.publish(session, files);
|
||||
} catch (_) {
|
||||
// Best effort: the extension just keeps the previous data.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'native_channel.dart';
|
||||
|
||||
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
||||
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
||||
@@ -40,7 +41,8 @@ class ShareIntentService {
|
||||
/// Whatever was shared to launch the app cold (empty if it was launched
|
||||
/// normally, not via a share).
|
||||
static Future<List<SharedFileRef>> getInitialShare() async {
|
||||
final result = await _methodChannel.invokeMethod<List<dynamic>>(
|
||||
final result = await invokeIfAvailable<List<dynamic>>(
|
||||
_methodChannel,
|
||||
'getInitialShare',
|
||||
);
|
||||
return (result ?? [])
|
||||
@@ -51,7 +53,7 @@ class ShareIntentService {
|
||||
|
||||
/// Emits whenever another share arrives while the app is already running.
|
||||
static Stream<List<SharedFileRef>> get onNewShare {
|
||||
return _newShareChannel.receiveBroadcastStream().map((event) {
|
||||
return quietEvents(_newShareChannel, (event) {
|
||||
return (event as List<dynamic>)
|
||||
.cast<Map<dynamic, dynamic>>()
|
||||
.map(SharedFileRef.fromMap)
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../models/saved_account.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../providers/session_controller.dart';
|
||||
import '../providers/sync_status_controller.dart';
|
||||
import 'native_channel.dart';
|
||||
|
||||
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
||||
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
||||
@@ -104,13 +105,18 @@ class SyncService {
|
||||
)
|
||||
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
||||
final password = await store.readPassword(account.id);
|
||||
if (password == null ||
|
||||
config.paths.isEmpty ||
|
||||
config.intervalMinutes == null) {
|
||||
if (password == null || config.paths.isEmpty) {
|
||||
await cancelAccount(account.id);
|
||||
continue;
|
||||
}
|
||||
await _channel.invokeMethod('reschedule', {
|
||||
if (config.intervalMinutes == null) {
|
||||
// Background sync is off, but this account still has paths to sync
|
||||
// by hand: keep its credentials (native uses them for "Sync now"
|
||||
// and conflict notification actions).
|
||||
await cancelAccount(account.id, forget: false);
|
||||
continue;
|
||||
}
|
||||
await invokeIfAvailable(_channel, 'reschedule', {
|
||||
'accountId': account.id,
|
||||
'serverUrl': account.serverUrl,
|
||||
'username': account.username,
|
||||
@@ -131,8 +137,17 @@ class SyncService {
|
||||
|
||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||
/// nothing left to sync).
|
||||
static Future<void> cancelAccount(String accountId) async {
|
||||
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
||||
///
|
||||
/// [forget] (the default) is for an account that was signed out or removed:
|
||||
/// native also drops its stored credentials. Pass false when only the
|
||||
/// background job is being turned off - the account's paths are still
|
||||
/// synced by hand ("Sync now"), and a conflict notification's actions
|
||||
/// still need its credentials.
|
||||
static Future<void> cancelAccount(String accountId, {bool forget = true}) async {
|
||||
await invokeIfAvailable(_channel, 'cancel', {
|
||||
'accountId': accountId,
|
||||
'forget': forget,
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||
@@ -158,7 +173,7 @@ class SyncService {
|
||||
}
|
||||
|
||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
||||
await _channel.invokeMethod('syncNow', {
|
||||
await invokeOrExplain(_channel, 'syncNow', 'Syncing', {
|
||||
'accountId': accountId,
|
||||
'serverUrl': session.serverUrl,
|
||||
'username': session.username,
|
||||
@@ -178,7 +193,8 @@ class SyncService {
|
||||
/// app start - the native bus only knows an account once a sync pass has
|
||||
/// run in this process.
|
||||
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
||||
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||
_channel,
|
||||
'getSyncStatus',
|
||||
{'accountId': accountId},
|
||||
);
|
||||
@@ -196,11 +212,10 @@ class SyncService {
|
||||
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
||||
/// was restarted. This is a broadcast stream, so any number of Dart
|
||||
/// listeners share the one native subscription.
|
||||
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
|
||||
.receiveBroadcastStream()
|
||||
.map(
|
||||
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
static final Stream<SyncStatusSnapshot> statusStream = quietEvents(
|
||||
_statusChannel,
|
||||
(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`
|
||||
@@ -213,7 +228,7 @@ class SyncService {
|
||||
) async {
|
||||
final authHeader = session.service?.authHeaders['Authorization'];
|
||||
if (authHeader == null) throw Exception('Not logged in.');
|
||||
await _channel.invokeMethod('resolveConflict', {
|
||||
await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', {
|
||||
'accountId': conflict.accountId,
|
||||
'serverUrl': session.serverUrl,
|
||||
'username': session.username,
|
||||
@@ -236,12 +251,23 @@ class SyncService {
|
||||
) async {
|
||||
final accountId = session.activeAccountId;
|
||||
if (accountId == null) return;
|
||||
await _channel.invokeMethod('removeLocalSync', {
|
||||
await invokeIfAvailable(_channel, 'removeLocalSync', {
|
||||
'accountId': accountId,
|
||||
'path': path,
|
||||
});
|
||||
}
|
||||
|
||||
/// Where the `sync/<accountId>/...` mirror lives: Android's app-specific
|
||||
/// external files dir (what `SyncEngine.kt` writes to); everywhere else -
|
||||
/// iOS has no external storage, `getExternalStorageDirectory` throws
|
||||
/// there - the app's private support directory.
|
||||
static Future<Directory?> baseDirectory() async {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
return getExternalStorageDirectory();
|
||||
}
|
||||
return getApplicationSupportDirectory();
|
||||
}
|
||||
|
||||
/// The deterministic local mirror path for [remoteItemPath] under
|
||||
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
||||
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
||||
@@ -251,7 +277,7 @@ class SyncService {
|
||||
String accountId,
|
||||
String remoteItemPath,
|
||||
) async {
|
||||
final base = await getExternalStorageDirectory();
|
||||
final base = await baseDirectory();
|
||||
if (base == null) return null;
|
||||
final relPath = remoteItemPath.startsWith('/')
|
||||
? remoteItemPath.substring(1)
|
||||
|
||||
@@ -56,7 +56,7 @@ class UploadService {
|
||||
.toList(),
|
||||
);
|
||||
|
||||
await _channel.invokeMethod('startUpload', {
|
||||
await invokeOrExplain(_channel, 'startUpload', 'Uploading', {
|
||||
...args,
|
||||
'files': filesJson,
|
||||
'remoteFolder': files.currentFolderPath,
|
||||
@@ -70,7 +70,8 @@ class UploadService {
|
||||
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
||||
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
||||
/// subscription per call, and the native side only keeps the latest one.
|
||||
static final Stream<UploadCompletion> completions = _statusChannel
|
||||
.receiveBroadcastStream()
|
||||
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
|
||||
static final Stream<UploadCompletion> completions = quietEvents(
|
||||
_statusChannel,
|
||||
(event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||
///
|
||||
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||
/// off the page.
|
||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
||||
final dark = cs.brightness == Brightness.dark;
|
||||
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||
return NooColors(
|
||||
bg: cs.surface,
|
||||
surface: cs.surfaceContainerLow,
|
||||
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
surface2: cs.surfaceContainerHigh,
|
||||
surface3: cs.surfaceContainerHighest,
|
||||
line: cs.outlineVariant,
|
||||
@@ -357,8 +366,8 @@ class NooMotion {
|
||||
|
||||
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
||||
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
||||
/// bar/search satellite (the only other thing in the app that's genuinely
|
||||
/// floating above other content). Nothing else should use a `BoxShadow`.
|
||||
/// bar/search satellite, and the floating selection action bar.
|
||||
/// Nothing else should use a `BoxShadow`.
|
||||
const nooDialogShadow = BoxShadow(
|
||||
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
||||
blurRadius: 48,
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/selection_bar_overlay.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
import 'file_viewer_screen.dart';
|
||||
@@ -54,8 +55,14 @@ class FavoritesView extends StatefulWidget {
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
final ValueChanged<bool>? onSelectionChanged;
|
||||
|
||||
const FavoritesView({super.key, required this.scrollController, this.topBar});
|
||||
const FavoritesView({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
this.topBar,
|
||||
this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FavoritesView> createState() => _FavoritesViewState();
|
||||
@@ -72,10 +79,12 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
setState(() {
|
||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||
});
|
||||
widget.onSelectionChanged?.call(_isSelecting);
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(_selectedIds.clear);
|
||||
widget.onSelectionChanged?.call(false);
|
||||
}
|
||||
|
||||
/// A favorited folder switches to the Files tab, navigated there; a
|
||||
@@ -483,6 +492,10 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectionAtBottom =
|
||||
_isSelecting &&
|
||||
context.watch<SettingsController>().selectionBarPosition ==
|
||||
SelectionBarPosition.bottom;
|
||||
final colors = context.nooColors;
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
final favoritesController = context.watch<FavoritesController>();
|
||||
@@ -513,15 +526,17 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
|
||||
final contentSlivers = <Widget>[
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
SliverPersistentHeader(
|
||||
pinned: !_isSelecting,
|
||||
delegate: StickyHeaderDelegate(
|
||||
height: _isSelecting ? 56 : 64,
|
||||
child: _isSelecting
|
||||
? _buildSelectionBar(context, selectedItems)
|
||||
: controlsRow,
|
||||
if (!selectionAtBottom)
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: StickyHeaderDelegate(
|
||||
floating: _isSelecting,
|
||||
height: _isSelecting ? 56 : 64,
|
||||
child: _isSelecting
|
||||
? _buildSelectionBar(context, selectedItems)
|
||||
: controlsRow,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (favoritesController.isLoading && favorites.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
@@ -646,7 +661,13 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
}, childCount: favorites.length),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height:
|
||||
bottomBarClearance(context) +
|
||||
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
@@ -654,24 +675,29 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelecting) _clearSelection();
|
||||
},
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: favoritesController.fetchAll,
|
||||
// See files_view.dart's identical fix - without this, the sticky
|
||||
// controls row rides up under the status bar once the floating
|
||||
// top bar above it fully collapses.
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// See files_view.dart's identical fix - without this, pull-to-
|
||||
// refresh can't be triggered on an empty or single-item list.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: contentSlivers,
|
||||
child: SelectionBarOverlay(
|
||||
bar: selectionAtBottom
|
||||
? _buildSelectionBar(context, selectedItems)
|
||||
: null,
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: favoritesController.fetchAll,
|
||||
// See files_view.dart's identical fix - without this, the sticky
|
||||
// controls row rides up under the status bar once the floating
|
||||
// top bar above it fully collapses.
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// See files_view.dart's identical fix - without this, pull-to-
|
||||
// refresh can't be triggered on an empty or single-item list.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: contentSlivers,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -178,6 +178,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
|
||||
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
||||
|
||||
/// Edit/Preview + Save for a text/markdown file: the preview owns the
|
||||
/// editing, this just lets the top bar draw (and trigger) its buttons.
|
||||
final _textActions = TextPreviewController();
|
||||
|
||||
void _openDetails() {
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
DetailsSheet.show(context, _currentItem);
|
||||
@@ -229,6 +233,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textActions.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -423,9 +428,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
// The stage follows the app's own theme (`bg`) rather than being pinned
|
||||
// to black - a black stage in light mode read as jarringly out of place
|
||||
// next to the rest of the light-themed app. The floating top/action
|
||||
// bars now follow the theme too (FrostedGlassContainer's own `surface`
|
||||
// default), at a higher blur opacity to stay legible over arbitrary
|
||||
// photo/video brightness underneath.
|
||||
// bars follow the theme and share the navigation frost preference.
|
||||
final stageColor = colors.bg;
|
||||
|
||||
return Scaffold(
|
||||
@@ -532,7 +535,14 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Edit/Preview and Save for text/markdown files -
|
||||
// empty (zero-width) for every other file type.
|
||||
ListenableBuilder(
|
||||
listenable: _textActions,
|
||||
builder: (context, _) =>
|
||||
_TextActions(controller: _textActions),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -731,6 +741,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
return MediaTextPreview(
|
||||
item: widget.item,
|
||||
session: session,
|
||||
controller: _textActions,
|
||||
localPathResolver: widget.localPathResolver,
|
||||
);
|
||||
}
|
||||
@@ -743,3 +754,103 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text viewer's top-bar buttons: Edit/Preview (markdown only) and Save
|
||||
/// (once the content changed; a spinner while it saves). Same
|
||||
/// round [NooButton] look (primary for the main action, secondary beside it).
|
||||
class _TextActions extends StatelessWidget {
|
||||
final TextPreviewController controller;
|
||||
|
||||
const _TextActions({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final toggle = controller.canToggleEditing;
|
||||
final save = controller.saving || controller.showSave;
|
||||
// One button is the primary action; with two, Save is and Edit/Preview
|
||||
// steps back to secondary.
|
||||
final both = toggle && save;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (toggle)
|
||||
_ActionButton(
|
||||
icon: controller.editing ? LucideIcons.eye : LucideIcons.pencil,
|
||||
tooltip: controller.editing ? 'Preview' : 'Edit',
|
||||
variant: both
|
||||
? NooButtonVariant.secondary
|
||||
: NooButtonVariant.primary,
|
||||
onTap: controller.toggleEditing,
|
||||
),
|
||||
if (both) const SizedBox(width: 8),
|
||||
if (controller.saving)
|
||||
const _SavingIndicator()
|
||||
else if (save)
|
||||
_ActionButton(
|
||||
icon: LucideIcons.save,
|
||||
tooltip: 'Save',
|
||||
variant: NooButtonVariant.primary,
|
||||
onTap: controller.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A round icon-only [NooButton] with the tooltip and semantic label an
|
||||
/// icon-only button needs.
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final NooButtonVariant variant;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.variant,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
excludeSemantics: true,
|
||||
child: NooButton(
|
||||
icon: icon,
|
||||
iconOnly: true,
|
||||
variant: variant,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in for the Save button while it saves: the same primary circle,
|
||||
/// with a spinner, so it can't be tapped twice.
|
||||
class _SavingIndicator extends StatelessWidget {
|
||||
const _SavingIndicator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Semantics(
|
||||
label: 'Saving',
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.accent, shape: BoxShape.circle),
|
||||
child: const SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart';
|
||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/selection_bar_overlay.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
import 'file_viewer_screen.dart';
|
||||
@@ -64,18 +65,71 @@ class FilesView extends StatefulWidget {
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
final ValueChanged<bool>? onSelectionChanged;
|
||||
|
||||
const FilesView({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
this.offline = false,
|
||||
this.topBar,
|
||||
this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FilesView> createState() => _FilesViewState();
|
||||
}
|
||||
|
||||
/// The collapsible "External storage" header [StorageScope.all] puts above
|
||||
/// the external items.
|
||||
class _ExternalStorageHeader extends StatelessWidget {
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ExternalStorageHeader({
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: expanded,
|
||||
label: 'External storage',
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'External storage',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: NooText.body.copyWith(color: colors.fg3)),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
color: colors.fg3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||
/// and remounts the Element instead of just updating it in place - that's
|
||||
@@ -236,6 +290,9 @@ class _GridThumbnail extends StatelessWidget {
|
||||
|
||||
class _FilesViewState extends State<FilesView> {
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||
bool _externalExpanded = true;
|
||||
int _lastPathDepth = 1;
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
@@ -245,9 +302,19 @@ class _FilesViewState extends State<FilesView> {
|
||||
/// The folder data source for this tab. Watches the concrete controller
|
||||
/// (that's what's registered as a provider), then hands it back as the
|
||||
/// [FolderBrowser] the rest of the view actually needs.
|
||||
FolderBrowser _browserOf(BuildContext context) => _offline
|
||||
? context.watch<OfflineController>()
|
||||
: context.watch<FilesController>();
|
||||
///
|
||||
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||
/// of `build` asserts in debug builds (silently swallowing the tap).
|
||||
FolderBrowser _browserOf(BuildContext context, {bool listen = true}) {
|
||||
if (listen) {
|
||||
return _offline
|
||||
? context.watch<OfflineController>()
|
||||
: context.watch<FilesController>();
|
||||
}
|
||||
return _offline
|
||||
? context.read<OfflineController>()
|
||||
: context.read<FilesController>();
|
||||
}
|
||||
|
||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
||||
/// thumbnails come from server previews instead.
|
||||
@@ -273,10 +340,12 @@ class _FilesViewState extends State<FilesView> {
|
||||
setState(() {
|
||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||
});
|
||||
widget.onSelectionChanged?.call(_isSelecting);
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(_selectedIds.clear);
|
||||
widget.onSelectionChanged?.call(false);
|
||||
}
|
||||
|
||||
/// Routes a tap on an item, in picking, selecting or plain-browsing mode
|
||||
@@ -287,7 +356,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
NextcloudItem item, {
|
||||
required bool picking,
|
||||
}) {
|
||||
final browser = _browserOf(context);
|
||||
final browser = _browserOf(context, listen: false);
|
||||
if (picking) {
|
||||
_handlePickTap(context, item);
|
||||
} else if (_isSelecting) {
|
||||
@@ -588,6 +657,10 @@ class _FilesViewState extends State<FilesView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectionAtBottom =
|
||||
_isSelecting &&
|
||||
context.watch<SettingsController>().selectionBarPosition ==
|
||||
SelectionBarPosition.bottom;
|
||||
final colors = context.nooColors;
|
||||
// Display prefs (grid/list, filters, sort) live on FilesController for
|
||||
// both tabs; `browser` is the tab's own folder listing.
|
||||
@@ -659,23 +732,149 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
);
|
||||
|
||||
// One sliver group per layout (grid / desktop table / mobile list) for
|
||||
// an arbitrary slice of items - [StorageScope.all] renders two of them,
|
||||
// the regular items and then the external ones under a collapsible
|
||||
// header, instead of one list over `browser.items`.
|
||||
List<Widget> itemSlivers(
|
||||
List<NextcloudItem> items, {
|
||||
required String section,
|
||||
bool tableHeader = true,
|
||||
}) {
|
||||
if (files.isGridView) {
|
||||
return [
|
||||
SliverPadding(
|
||||
key: ValueKey('files-grid-$section'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isDesktop) {
|
||||
return [
|
||||
if (tableHeader)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: ValueKey('files-table-$section'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
SliverPadding(
|
||||
key: ValueKey('files-list-$section'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(context, item, index, items.length),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// "All + external": external items leave the main list for their own
|
||||
// collapsible section below it. Offline has no such distinction.
|
||||
final splitExternal = !_offline && files.storageScope == StorageScope.all;
|
||||
final mainItems = splitExternal
|
||||
? browser.items.where((i) => !i.isExternalStorage).toList()
|
||||
: browser.items;
|
||||
final externalItems = splitExternal
|
||||
? browser.items.where((i) => i.isExternalStorage).toList()
|
||||
: const <NextcloudItem>[];
|
||||
final bodySlivers = <Widget>[
|
||||
if (mainItems.isNotEmpty) ...itemSlivers(mainItems, section: 'main'),
|
||||
if (externalItems.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _ExternalStorageHeader(
|
||||
count: externalItems.length,
|
||||
expanded: _externalExpanded,
|
||||
onTap: () => setState(() => _externalExpanded = !_externalExpanded),
|
||||
),
|
||||
),
|
||||
if (_externalExpanded)
|
||||
...itemSlivers(
|
||||
externalItems,
|
||||
section: 'external',
|
||||
tableHeader: mainItems.isEmpty,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Pinned in both states - while browsing this is the controls row
|
||||
// (+ breadcrumbs), while selecting it's the selection bar (see
|
||||
// `topRow` above): either way it's the one thing that always stays
|
||||
// at the very top of the scroll view.
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: StickyHeaderDelegate(
|
||||
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
|
||||
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
|
||||
// Getting this wrong overflows the sliver header by exactly the
|
||||
// shortfall - a real bug this shipped with once already.
|
||||
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
|
||||
child: topRow,
|
||||
if (!selectionAtBottom)
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: StickyHeaderDelegate(
|
||||
floating: _isSelecting,
|
||||
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
|
||||
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
|
||||
// Getting this wrong overflows the sliver header by exactly the
|
||||
// shortfall - a real bug this shipped with once already.
|
||||
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
|
||||
child: topRow,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_offline)
|
||||
SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)),
|
||||
// Files List / Grid
|
||||
@@ -743,96 +942,24 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (files.isGridView)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-grid'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
)
|
||||
else if (isDesktop) ...[
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
] else
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-list'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(
|
||||
context,
|
||||
item,
|
||||
index,
|
||||
browser.items.length,
|
||||
),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
else
|
||||
...bodySlivers,
|
||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||
// once Scaffold shrinks the body above an attached bar, but floating
|
||||
// draws the body behind the bar instead, so it needs the bar's own
|
||||
// footprint added on top or the last row ends up under it.
|
||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
||||
NooBottomBarStyle.floating)
|
||||
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||
// needs the bar's own footprint added on top or the last row ends up
|
||||
// under it.
|
||||
if (selectionAtBottom ||
|
||||
NooBottomBar.drawsBehindBody(
|
||||
context.watch<SettingsController>().bottomBarStyle,
|
||||
context.watch<SettingsController>().bottomBarFrosted,
|
||||
))
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: bottomBarClearance(context)),
|
||||
child: SizedBox(
|
||||
height:
|
||||
bottomBarClearance(context) +
|
||||
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -846,38 +973,43 @@ class _FilesViewState extends State<FilesView> {
|
||||
browser.navigateUp();
|
||||
}
|
||||
},
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: () {
|
||||
unawaited(sync.syncOnPull());
|
||||
return browser.reload();
|
||||
},
|
||||
// `topBarSliver`'s floating header can collapse all the way to
|
||||
// zero height (fully scrolled away), at which point the sticky
|
||||
// controls row right below it in `contentSlivers` would otherwise
|
||||
// ride up underneath the status bar instead of stopping below it
|
||||
// - the floating top bar used to be the only thing reserving that
|
||||
// space (via its own internal `SafeArea`), and that reservation
|
||||
// disappears along with it once it's fully hidden. Wrapping the
|
||||
// whole scroll view keeps the inset outside the scrolling region
|
||||
// entirely, so it's never implicated in the floating header's own
|
||||
// collapse/reveal math - safe to apply unconditionally, since
|
||||
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
|
||||
// status bar there).
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// Pull-to-refresh needs a scroll physics that allows dragging
|
||||
// past the edge even when content doesn't fill the viewport -
|
||||
// an empty or single-item list otherwise can't be pulled at
|
||||
// all under the platform default physics.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: contentSlivers,
|
||||
child: SelectionBarOverlay(
|
||||
bar: selectionAtBottom
|
||||
? _buildSelectionBar(context, selectedItems)
|
||||
: null,
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: () {
|
||||
unawaited(sync.syncOnPull());
|
||||
return browser.reload();
|
||||
},
|
||||
// `topBarSliver`'s floating header can collapse all the way to
|
||||
// zero height (fully scrolled away), at which point the sticky
|
||||
// controls row right below it in `contentSlivers` would otherwise
|
||||
// ride up underneath the status bar instead of stopping below it
|
||||
// - the floating top bar used to be the only thing reserving that
|
||||
// space (via its own internal `SafeArea`), and that reservation
|
||||
// disappears along with it once it's fully hidden. Wrapping the
|
||||
// whole scroll view keeps the inset outside the scrolling region
|
||||
// entirely, so it's never implicated in the floating header's own
|
||||
// collapse/reveal math - safe to apply unconditionally, since
|
||||
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
|
||||
// status bar there).
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// Pull-to-refresh needs a scroll physics that allows dragging
|
||||
// past the edge even when content doesn't fill the viewport -
|
||||
// an empty or single-item list otherwise can't be pulled at
|
||||
// all under the platform default physics.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: contentSlivers,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,10 +15,10 @@ import '../providers/settings_controller.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/details/details_sheet.dart';
|
||||
import '../widgets/filter_mode_rows.dart';
|
||||
import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/noo/core/noo_chip.dart';
|
||||
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||
import '../widgets/noo/core/noo_toggle.dart';
|
||||
import '../widgets/noo/files/noo_file_kind.dart';
|
||||
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||
@@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/selection_bar_overlay.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
import 'move_copy_destination_picker.dart';
|
||||
@@ -57,8 +58,14 @@ class PhotosView extends StatefulWidget {
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
final ValueChanged<bool>? onSelectionChanged;
|
||||
|
||||
const PhotosView({super.key, required this.scrollController, this.topBar});
|
||||
const PhotosView({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
this.topBar,
|
||||
this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PhotosView> createState() => _PhotosViewState();
|
||||
@@ -81,10 +88,12 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
setState(() {
|
||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||
});
|
||||
widget.onSelectionChanged?.call(_isSelecting);
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(_selectedIds.clear);
|
||||
widget.onSelectionChanged?.call(false);
|
||||
}
|
||||
|
||||
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
||||
@@ -214,6 +223,10 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectionAtBottom =
|
||||
_isSelecting &&
|
||||
context.watch<SettingsController>().selectionBarPosition ==
|
||||
SelectionBarPosition.bottom;
|
||||
final colors = context.nooColors;
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
final photosController = context.watch<PhotosController>();
|
||||
@@ -247,23 +260,25 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Sticky while browsing; once selecting, the selection bar takes over
|
||||
// the same slot instead.
|
||||
SliverPersistentHeader(
|
||||
pinned: !_isSelecting,
|
||||
delegate: StickyHeaderDelegate(
|
||||
height: _isSelecting ? 56 : 64,
|
||||
child: _isSelecting
|
||||
? _buildSelectionBar(context, pick, selectedItems)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
if (!selectionAtBottom)
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: StickyHeaderDelegate(
|
||||
floating: _isSelecting,
|
||||
height: _isSelecting ? 56 : 64,
|
||||
child: _isSelecting
|
||||
? _buildSelectionBar(context, pick, selectedItems)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
),
|
||||
child: controlsRow,
|
||||
),
|
||||
child: controlsRow,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (photosController.isLoading && allPhotos.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
@@ -359,7 +374,13 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
// Clearance so the last row isn't hidden behind the nav bar,
|
||||
// regardless of grid length - see `bottomBarClearance`'s own doc
|
||||
// comment for why this has to be dynamic rather than a flat 100.
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height:
|
||||
bottomBarClearance(context) +
|
||||
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
@@ -367,24 +388,29 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelecting) _clearSelection();
|
||||
},
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: photosController.fetchAllMedia,
|
||||
// See files_view.dart's identical fix - without this, the sticky
|
||||
// controls row rides up under the status bar once the floating
|
||||
// top bar above it fully collapses.
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// See files_view.dart's identical fix - without this, pull-to-
|
||||
// refresh can't be triggered on an empty or single-item list.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: contentSlivers,
|
||||
child: SelectionBarOverlay(
|
||||
bar: selectionAtBottom
|
||||
? _buildSelectionBar(context, pick, selectedItems)
|
||||
: null,
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: photosController.fetchAllMedia,
|
||||
// See files_view.dart's identical fix - without this, the sticky
|
||||
// controls row rides up under the status bar once the floating
|
||||
// top bar above it fully collapses.
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// See files_view.dart's identical fix - without this, pull-to-
|
||||
// refresh can't be triggered on an empty or single-item list.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: contentSlivers,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -404,7 +430,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
final filtersActive =
|
||||
_typeFilter != _PhotoTypeFilter.all ||
|
||||
photos.showFavoritesOnly ||
|
||||
photos.showHidden ||
|
||||
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
files.storageScope != StorageScope.cloud;
|
||||
|
||||
// A plain Row, not a horizontally-scrolling one - see
|
||||
@@ -521,63 +547,74 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
// Same "icon always, label only when selected" pill as
|
||||
// Files' own type filter - not a fully-labelled track, so
|
||||
// the two screens' filter sheets look and behave the same.
|
||||
NooSegmentedControl<_PhotoTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: _typeFilter,
|
||||
onChanged: (filter) => setState(() {
|
||||
_typeFilter = filter;
|
||||
setSheetState(() {});
|
||||
}),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.image,
|
||||
icon: LucideIcons.image,
|
||||
label: 'Photos',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.video,
|
||||
icon: LucideIcons.film,
|
||||
label: 'Videos',
|
||||
),
|
||||
],
|
||||
FilterSection(
|
||||
icon: LucideIcons.layoutGrid,
|
||||
title: 'Type',
|
||||
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: _typeFilter,
|
||||
onChanged: (filter) => setState(() {
|
||||
_typeFilter = filter;
|
||||
setSheetState(() {});
|
||||
}),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.image,
|
||||
icon: LucideIcons.image,
|
||||
label: 'Photos',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.video,
|
||||
icon: LucideIcons.film,
|
||||
label: 'Videos',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.heart,
|
||||
label: const Text('Favorites only'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showFavoritesOnly,
|
||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
||||
FilterSection(
|
||||
icon: LucideIcons.heart,
|
||||
title: 'Favorites',
|
||||
child: NooSegmentedControl<bool>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: photos.showFavoritesOnly,
|
||||
onChanged: (value) {
|
||||
if (value != photos.showFavoritesOnly) {
|
||||
photos.toggleFavoritesFilter();
|
||||
}
|
||||
},
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: false,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All',
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showHidden,
|
||||
onChanged: (_) => photos.toggleShowHidden(),
|
||||
NooSegmentOption(
|
||||
value: true,
|
||||
icon: LucideIcons.heart,
|
||||
label: 'Favorites only',
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
HiddenFilesFilterRow(
|
||||
value: photos.hiddenFilter,
|
||||
onChanged: photos.setHiddenFilter,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -24,6 +24,7 @@ Widget buildAppTabView(
|
||||
AppTab tab,
|
||||
ScrollController controller, {
|
||||
PreferredSizeWidget? topBar,
|
||||
ValueChanged<bool>? onSelectionChanged,
|
||||
}) {
|
||||
switch (tab) {
|
||||
case AppTab.files:
|
||||
@@ -33,11 +34,20 @@ Widget buildAppTabView(
|
||||
key: const ValueKey('files'),
|
||||
scrollController: controller,
|
||||
topBar: topBar,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
case AppTab.photos:
|
||||
return PhotosView(scrollController: controller, topBar: topBar);
|
||||
return PhotosView(
|
||||
scrollController: controller,
|
||||
topBar: topBar,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
case AppTab.favorites:
|
||||
return FavoritesView(scrollController: controller, topBar: topBar);
|
||||
return FavoritesView(
|
||||
scrollController: controller,
|
||||
topBar: topBar,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
case AppTab.activity:
|
||||
return ActivityView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.trash:
|
||||
@@ -52,6 +62,7 @@ Widget buildAppTabView(
|
||||
scrollController: controller,
|
||||
offline: true,
|
||||
topBar: topBar,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../providers/settings_controller.dart';
|
||||
import 'avatar_menu.dart';
|
||||
import 'create_menu.dart';
|
||||
import 'noo/nav/noo_top_bar.dart';
|
||||
import 'noo/core/noo_button.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'shell/shell_common.dart';
|
||||
|
||||
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
|
||||
@@ -11,9 +14,9 @@ import 'shell/shell_common.dart';
|
||||
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
|
||||
/// Built on [NooTopBar]:
|
||||
///
|
||||
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
|
||||
/// create/upload flow) and the account avatar trailing, then the large
|
||||
/// title, then an inline search field that opens [SearchView].
|
||||
/// - iOS: large title, Upload button and account avatar in one row, then
|
||||
/// an inline search field that opens [SearchView]. Upload shares Android's
|
||||
/// Auto/Collapsed/Expanded preference.
|
||||
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
||||
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
||||
/// isn't in the bottom bar, an inline search-field-styled launcher
|
||||
@@ -37,6 +40,8 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final AppTab tab;
|
||||
final bool searchInBottomBar;
|
||||
final NooNavMenuStyle navMenuStyle;
|
||||
final AvatarPosition avatarPosition;
|
||||
final FabStyle uploadButtonStyle;
|
||||
|
||||
const AppTopBar({
|
||||
super.key,
|
||||
@@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
required this.tab,
|
||||
this.searchInBottomBar = false,
|
||||
this.navMenuStyle = NooNavMenuStyle.drawer,
|
||||
this.avatarPosition = AvatarPosition.top,
|
||||
this.uploadButtonStyle = FabStyle.auto,
|
||||
});
|
||||
|
||||
bool get _ios => style == NooNavStyle.ios;
|
||||
bool get _showSearch => !searchInBottomBar;
|
||||
bool get _uploadCollapsed => switch (uploadButtonStyle) {
|
||||
FabStyle.auto => tab != AppTab.files && tab != AppTab.photos,
|
||||
FabStyle.mini => true,
|
||||
FabStyle.expanded => false,
|
||||
};
|
||||
|
||||
@override
|
||||
Size get preferredSize => NooTopBar(
|
||||
style: style,
|
||||
title: tab.label,
|
||||
iosActionsInTitleRow: true,
|
||||
// A throwaway placeholder purely to make preferredSize's "is there a
|
||||
// search slot" check match what build() actually shows - never
|
||||
// inserted into the tree, so its content doesn't matter.
|
||||
@@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
return NooTopBar(
|
||||
style: style,
|
||||
title: tab.label,
|
||||
iosActionsInTitleRow: true,
|
||||
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
||||
? () => Scaffold.of(context).openDrawer()
|
||||
: null,
|
||||
@@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
? const ShellSearchLauncher()
|
||||
: null,
|
||||
actions: [
|
||||
if (_ios && tab == AppTab.files)
|
||||
NooTopBarButton(
|
||||
icon: LucideIcons.plus,
|
||||
tooltip: 'Create',
|
||||
onTap: () => showCreateMenu(context),
|
||||
if (_ios)
|
||||
Tooltip(
|
||||
message: 'Upload',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Upload',
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
Widget button(double progress) => NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
icon: LucideIcons.plus,
|
||||
expansion: progress,
|
||||
onTap: () => showCreateMenu(context),
|
||||
child: const Text('Upload'),
|
||||
);
|
||||
final scope = context
|
||||
.dependOnInheritedWidgetOfExactType<
|
||||
UploadButtonAnimation
|
||||
>();
|
||||
if (scope != null) return button(scope.progress);
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: _uploadCollapsed ? 0 : 1),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: NooMotion.base,
|
||||
curve: NooMotion.ease,
|
||||
builder: (context, progress, _) => button(progress),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (navMenuStyle != NooNavMenuStyle.avatarMenu ||
|
||||
avatarPosition == AvatarPosition.top)
|
||||
AvatarNavigationAnchor(
|
||||
child: Builder(
|
||||
builder: (anchorContext) => ShellAvatarButton(
|
||||
hitBox: _ios ? 44 : 48,
|
||||
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||
? () => showAvatarMenu(anchorContext)
|
||||
: null,
|
||||
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||
? 'Menu'
|
||||
: 'Accounts',
|
||||
),
|
||||
),
|
||||
),
|
||||
ShellAvatarButton(
|
||||
hitBox: _ios ? 44 : 48,
|
||||
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||
? () => showAvatarMenu(context)
|
||||
: null,
|
||||
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||
? 'Menu'
|
||||
: 'Accounts',
|
||||
),
|
||||
],
|
||||
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares one transition across the shell's separately mounted tab headers.
|
||||
class UploadButtonAnimation extends InheritedWidget {
|
||||
final double progress;
|
||||
const UploadButtonAnimation({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required super.child,
|
||||
});
|
||||
@override
|
||||
bool updateShouldNotify(UploadButtonAnimation oldWidget) =>
|
||||
progress != oldWidget.progress;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui' show ImageFilter, lerpDouble;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -6,53 +7,85 @@ import '../providers/session_controller.dart';
|
||||
import '../providers/settings_controller.dart';
|
||||
import '../providers/trash_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_avatar.dart';
|
||||
import '../views/login_view.dart';
|
||||
import 'noo/core/noo_avatar.dart';
|
||||
import 'noo/core/noo_badge.dart';
|
||||
import 'noo/nav/noo_bottom_bar.dart';
|
||||
import 'noo/noo_layout.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'shell/shell_common.dart';
|
||||
|
||||
/// The dropdown [ShellAvatarButton] opens when
|
||||
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] -
|
||||
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs,
|
||||
/// Settings), reached from the avatar instead of a hamburger-triggered
|
||||
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/
|
||||
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming)
|
||||
/// barrier and a card anchored under the top bar rather than a modal
|
||||
/// sheet/dialog, and there's no existing anchored-popup primitive in this
|
||||
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
|
||||
/// content column the way this needs to).
|
||||
Future<void> showAvatarMenu(BuildContext context) {
|
||||
return showGeneralDialog<void>(
|
||||
/// The shell supplies the expanding-card presentation. Standalone top bars
|
||||
/// retain a dialog fallback (for screens outside the shell).
|
||||
Future<void> showAvatarMenu(BuildContext context) async {
|
||||
final scope = AvatarNavigationScope.maybeOf(context);
|
||||
if (scope != null) {
|
||||
scope._host.openFrom(context);
|
||||
return;
|
||||
}
|
||||
await showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Close menu',
|
||||
transitionDuration: NooMotion.fast,
|
||||
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
|
||||
transitionDuration: NooMotion.base,
|
||||
pageBuilder: (context, _, _) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||
child: _StandaloneAvatarCard(),
|
||||
),
|
||||
),
|
||||
),
|
||||
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
alignment: Alignment.topRight,
|
||||
scale: Tween<double>(
|
||||
begin: 0.96,
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
|
||||
scale: Tween(begin: .88, end: 1.0).animate(
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AvatarMenuContent extends StatefulWidget {
|
||||
const _AvatarMenuContent();
|
||||
|
||||
class _StandaloneAvatarCard extends StatefulWidget {
|
||||
@override
|
||||
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
|
||||
State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState();
|
||||
}
|
||||
|
||||
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
||||
bool _expanded = false;
|
||||
class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> {
|
||||
bool expanded = false;
|
||||
@override
|
||||
Widget build(BuildContext context) => AvatarMenuCard(
|
||||
expanded: expanded,
|
||||
onExpand: () => setState(() => expanded = !expanded),
|
||||
onClose: () => Navigator.pop(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Account controls and hidden-tab navigation, shared by both card positions.
|
||||
class AvatarMenuCard extends StatelessWidget {
|
||||
final bool bottom;
|
||||
final bool expanded;
|
||||
final bool movingAvatar;
|
||||
final bool showHeaderAvatar;
|
||||
final Key? headerAvatarKey;
|
||||
final VoidCallback onExpand;
|
||||
final VoidCallback onClose;
|
||||
const AvatarMenuCard({
|
||||
super.key,
|
||||
this.bottom = false,
|
||||
this.movingAvatar = false,
|
||||
this.showHeaderAvatar = true,
|
||||
this.headerAvatarKey,
|
||||
required this.expanded,
|
||||
required this.onExpand,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -60,224 +93,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
||||
final session = context.watch<SessionController>();
|
||||
final settings = context.watch<SettingsController>();
|
||||
final trashCount = context.watch<TrashController>().items.length;
|
||||
final hiddenTabs = settings.tabOrder
|
||||
.where((t) => settings.hiddenTabs.contains(t))
|
||||
.toList();
|
||||
|
||||
void closeAndOpenSettings() {
|
||||
Navigator.pop(context);
|
||||
void settingsTap() {
|
||||
onClose();
|
||||
openSettings(context);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
// Just the status-bar inset, not the top bar's own height on top of
|
||||
// it - the card covers the top bar (title included) rather than
|
||||
// sitting below it, so opening it reads as the avatar growing into
|
||||
// this instead of a separate element appearing underneath the row
|
||||
// it came from.
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
// No `color`/`clipBehavior` here - a `Container` with both a
|
||||
// `border` and `clipBehavior` set paints the border as part
|
||||
// of its *outer* decoration, then paints its (clipped) child
|
||||
// on top right up to that same boundary with no gap for the
|
||||
// border's own stroke width to show through. An opaque child
|
||||
// touching that edge - every row below has its own full-bleed
|
||||
// `Material` fill - then paints straight over the inner half
|
||||
// of the border, which read as the border going missing
|
||||
// specifically wherever an opaque row sits (every corner but
|
||||
// the two by the header, which has no opaque fill of its
|
||||
// own). The 1px `Padding` + inset `ClipRRect` below keeps the
|
||||
// clipped, filled content entirely inside the border's own
|
||||
// stroke instead of racing it for the same pixels.
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
// `nooDialogShadow` alone is a wide, soft, fairly faint
|
||||
// shadow - built for a desktop dialog with plenty of room
|
||||
// to fall off into. On a small card over a dark theme's
|
||||
// near-black `bg`, that falloff is too gradual to read as
|
||||
// elevation at all (a dark shadow needs real density close
|
||||
// to the edge to be visible against an already-dark
|
||||
// backdrop). A second, tighter, more opaque contact shadow
|
||||
// underneath it gives an immediate value-step right at the
|
||||
// card's edge in both themes, with the soft one still
|
||||
// doing the wider ambient falloff on top.
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x40000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
nooDialogShadow,
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card - 1),
|
||||
child: Material(
|
||||
color: colors.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Avatar on the right, name/host on the left - mirrors the
|
||||
// top bar's own right-aligned avatar exactly, so this
|
||||
// header reads as a continuation of the button that
|
||||
// opened it rather than a disconnected card.
|
||||
InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
session.username,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
serverHost(session.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(
|
||||
color: colors.fg3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colors.fg1.withValues(alpha: 0.08),
|
||||
),
|
||||
child: Icon(
|
||||
_expanded
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 20,
|
||||
color: colors.fg2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _expanded
|
||||
? Column(
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
if (account.id != session.activeAccountId)
|
||||
_OtherAccountRow(
|
||||
name: account.username,
|
||||
host: serverHost(account.serverUrl),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
session.switchAccount(account.id);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.md,
|
||||
0,
|
||||
NooSpace.md,
|
||||
NooSpace.md,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AccountButton(
|
||||
icon: LucideIcons.userPlus,
|
||||
label: 'Add Account',
|
||||
onTap: () {
|
||||
final nav = Navigator.of(
|
||||
context,
|
||||
);
|
||||
nav.pop();
|
||||
nav.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const LoginView(
|
||||
isAddingAccount: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _AccountButton(
|
||||
icon: LucideIcons.users,
|
||||
label: 'Manage Accounts',
|
||||
onTap: closeAndOpenSettings,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
if (hiddenTabs.isNotEmpty) ...[
|
||||
Divider(height: 1, color: colors.line),
|
||||
for (final tab in hiddenTabs)
|
||||
NooSettingsRow(
|
||||
icon: tab.icon,
|
||||
label: Text(tab.label),
|
||||
trailing: tab == AppTab.trash && trashCount > 0
|
||||
? NooBadge(
|
||||
tone: NooBadgeTone.accent,
|
||||
child: Text('$trashCount'),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
settings.requestTab(tab);
|
||||
},
|
||||
),
|
||||
],
|
||||
Divider(height: 1, color: colors.line),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.settings,
|
||||
label: const Text('Settings'),
|
||||
onTap: closeAndOpenSettings,
|
||||
),
|
||||
],
|
||||
final header = InkWell(
|
||||
onTap: onExpand,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
session.username,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
serverHost(session.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
size: 20,
|
||||
color: colors.fg2,
|
||||
),
|
||||
if (showHeaderAvatar) const SizedBox(width: 12),
|
||||
if (showHeaderAvatar)
|
||||
movingAvatar
|
||||
? SizedBox.square(key: headerAvatarKey, dimension: 32)
|
||||
: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: NooAvatar(
|
||||
key: headerAvatarKey,
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
final accountSection = AnimatedSize(
|
||||
duration: NooMotion.base,
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter,
|
||||
child: expanded
|
||||
? Column(
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
if (account.id != session.activeAccountId)
|
||||
_OtherAccountRow(
|
||||
name: account.username,
|
||||
host: serverHost(account.serverUrl),
|
||||
onTap: () {
|
||||
onClose();
|
||||
session.switchAccount(account.id);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AccountButton(
|
||||
icon: LucideIcons.userPlus,
|
||||
label: 'Add Account',
|
||||
onTap: () {
|
||||
final nav = Navigator.of(context);
|
||||
onClose();
|
||||
nav.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const LoginView(isAddingAccount: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _AccountButton(
|
||||
icon: LucideIcons.users,
|
||||
label: 'Manage Accounts',
|
||||
onTap: settingsTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
);
|
||||
final rows = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!bottom) accountSection,
|
||||
for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains))
|
||||
NooSettingsRow(
|
||||
backgroundColor: Colors.transparent,
|
||||
icon: tab.icon,
|
||||
label: Text(tab.label),
|
||||
trailing: tab == AppTab.trash && trashCount > 0
|
||||
? NooBadge(
|
||||
tone: NooBadgeTone.accent,
|
||||
child: Text('$trashCount'),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
onClose();
|
||||
settings.requestTab(tab);
|
||||
},
|
||||
),
|
||||
Divider(height: 1, color: colors.line),
|
||||
NooSettingsRow(
|
||||
backgroundColor: Colors.transparent,
|
||||
icon: LucideIcons.settings,
|
||||
label: const Text('Settings'),
|
||||
onTap: settingsTap,
|
||||
),
|
||||
if (bottom) ...[header, accountSection],
|
||||
],
|
||||
);
|
||||
final content = SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card - 1),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!bottom) header,
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: SingleChildScrollView(child: rows),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return movingAvatar
|
||||
? content
|
||||
: _AvatarSurface(
|
||||
radius: BorderRadius.circular(NooRadii.card),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarSurface extends StatelessWidget {
|
||||
final BorderRadius radius;
|
||||
final Widget child;
|
||||
final bool connected;
|
||||
const _AvatarSurface({
|
||||
required this.radius,
|
||||
required this.child,
|
||||
this.connected = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final colors = context.nooColors;
|
||||
final frosted = settings.bottomBarFrosted;
|
||||
final surface = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: frosted
|
||||
? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity)
|
||||
: colors.surface,
|
||||
border: connected ? null : Border.all(color: colors.line),
|
||||
borderRadius: radius,
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(1), child: child),
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
boxShadow: frosted || connected ? null : const [nooDialogShadow],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: frosted
|
||||
? BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: settings.bottomBarFrostedBlur,
|
||||
sigmaY: settings.bottomBarFrostedBlur,
|
||||
),
|
||||
child: surface,
|
||||
)
|
||||
: surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AvatarNavigationScope extends InheritedNotifier<AnimationController> {
|
||||
final _AvatarNavigationHostState _host;
|
||||
AvatarNavigationScope._({
|
||||
required _AvatarNavigationHostState host,
|
||||
required super.child,
|
||||
}) : _host = host,
|
||||
super(notifier: host.animation);
|
||||
static AvatarNavigationScope? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<AvatarNavigationScope>();
|
||||
double get progress =>
|
||||
Curves.easeInOutCubicEmphasized.transform(_host.animation.value);
|
||||
bool get bottom => _host.bottom;
|
||||
void close() => _host.close();
|
||||
@override
|
||||
bool updateShouldNotify(AvatarNavigationScope oldWidget) => true;
|
||||
}
|
||||
|
||||
/// Keeps the top avatar's layout slot while the single animated avatar moves
|
||||
/// into the card. Its own context gives us the real screen-space origin.
|
||||
class AvatarNavigationAnchor extends StatelessWidget {
|
||||
final Widget child;
|
||||
const AvatarNavigationAnchor({super.key, required this.child});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = AvatarNavigationScope.maybeOf(context);
|
||||
return IgnorePointer(
|
||||
ignoring: scope != null && scope.progress > 0,
|
||||
child: ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
widthFactor: 1 - (scope?.progress ?? 0),
|
||||
child: Opacity(
|
||||
opacity: scope != null && scope.progress > 0 ? 0 : 1,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the content pane moves; the bottom navigation stays anchored.
|
||||
class AvatarNavigationBody extends StatelessWidget {
|
||||
final Widget child;
|
||||
const AvatarNavigationBody({super.key, required this.child});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = AvatarNavigationScope.maybeOf(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: scope == null || scope.bottom
|
||||
? 0
|
||||
: (scope._host.cardHeight + 12) * scope.progress,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AvatarNavigationSatellite extends StatelessWidget {
|
||||
final double size;
|
||||
const AvatarNavigationSatellite({super.key, required this.size});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = AvatarNavigationScope.maybeOf(context);
|
||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
||||
NooBottomBarStyle.attached) {
|
||||
return Builder(
|
||||
builder: (anchorContext) => ShellAvatarButton(
|
||||
hitBox: size,
|
||||
label: 'Menu',
|
||||
onTap: () => showAvatarMenu(anchorContext),
|
||||
),
|
||||
);
|
||||
}
|
||||
return IgnorePointer(
|
||||
ignoring: scope != null && scope.progress > 0,
|
||||
child: Opacity(
|
||||
opacity: scope != null && scope.progress > 0 ? 0 : 1,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: _AvatarSurface(
|
||||
radius: BorderRadius.circular(size / 2),
|
||||
child: Builder(
|
||||
builder: (anchorContext) => ShellAvatarButton(
|
||||
hitBox: size,
|
||||
label: 'Menu',
|
||||
onTap: () => showAvatarMenu(anchorContext),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -287,6 +406,286 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
||||
}
|
||||
}
|
||||
|
||||
class AvatarNavigationHost extends StatefulWidget {
|
||||
final Widget child;
|
||||
const AvatarNavigationHost({super.key, required this.child});
|
||||
@override
|
||||
State<AvatarNavigationHost> createState() => _AvatarNavigationHostState();
|
||||
}
|
||||
|
||||
class _AvatarNavigationHostState extends State<AvatarNavigationHost>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController animation = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
reverseDuration: const Duration(milliseconds: 320),
|
||||
);
|
||||
final GlobalKey measureKey = GlobalKey();
|
||||
final GlobalKey measureAvatarKey = GlobalKey();
|
||||
Offset avatarOffset = const Offset(0, 17);
|
||||
Rect? origin;
|
||||
bool bottom = false;
|
||||
bool expanded = false;
|
||||
double cardHeight = 150;
|
||||
bool measuring = false;
|
||||
void measure() {
|
||||
if (measuring) return;
|
||||
measuring = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
measuring = false;
|
||||
if (!mounted) return;
|
||||
final box = measureKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final avatarBox =
|
||||
measureAvatarKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final offset = box != null && avatarBox != null
|
||||
? avatarBox.localToGlobal(Offset.zero, ancestor: box)
|
||||
: avatarOffset;
|
||||
if (box != null &&
|
||||
((box.size.height - cardHeight).abs() > .5 ||
|
||||
(offset - avatarOffset).distance > .5)) {
|
||||
setState(() {
|
||||
cardHeight = box.size.height;
|
||||
avatarOffset = offset;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void openFrom(BuildContext context) {
|
||||
if (animation.value > 0) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
final settings = context.read<SettingsController>();
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
final hostBox = this.context.findRenderObject() as RenderBox;
|
||||
final center = hostBox.globalToLocal(
|
||||
box.localToGlobal(box.size.center(Offset.zero)),
|
||||
);
|
||||
setState(() {
|
||||
bottom = settings.avatarPosition == AvatarPosition.bottom;
|
||||
final diameter = bottom
|
||||
? NooBottomBar.rowHeight(
|
||||
NooLayout.navStyle(context),
|
||||
settings.bottomBarStyle,
|
||||
)
|
||||
: 32.0;
|
||||
origin = Rect.fromCenter(
|
||||
center: center,
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
);
|
||||
expanded = false;
|
||||
});
|
||||
animation.duration = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 420);
|
||||
animation.forward();
|
||||
}
|
||||
|
||||
void close() {
|
||||
animation.reverseDuration = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 320);
|
||||
animation.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
animation.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final session = context.watch<SessionController>();
|
||||
final enabled = settings.navMenuStyle == NooNavMenuStyle.avatarMenu;
|
||||
return AvatarNavigationScope._(
|
||||
host: this,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
final progress = Curves.easeInOutCubicEmphasized.transform(
|
||||
animation.value,
|
||||
);
|
||||
return PopScope(
|
||||
canPop: animation.value == 0,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) close();
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
final safeTop = MediaQuery.viewPaddingOf(context).top;
|
||||
final attached =
|
||||
bottom &&
|
||||
settings.bottomBarStyle == NooBottomBarStyle.attached;
|
||||
final bottomEdge = bottom && origin != null
|
||||
? origin!.center.dy -
|
||||
NooBottomBar.rowHeight(
|
||||
NooLayout.navStyle(context),
|
||||
settings.bottomBarStyle,
|
||||
) /
|
||||
2 -
|
||||
(attached ? 0 : 12)
|
||||
: height;
|
||||
final maxHeight = (bottomEdge - safeTop - 24).clamp(
|
||||
72.0,
|
||||
height * .8,
|
||||
);
|
||||
measure();
|
||||
Widget card({bool measurement = false}) => ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: AvatarMenuCard(
|
||||
bottom: bottom,
|
||||
movingAvatar: measurement || progress < 1,
|
||||
headerAvatarKey: measurement ? measureAvatarKey : null,
|
||||
showHeaderAvatar: !attached,
|
||||
expanded: expanded,
|
||||
onExpand: () => setState(() => expanded = !expanded),
|
||||
onClose: close,
|
||||
),
|
||||
);
|
||||
final target = Rect.fromLTWH(
|
||||
attached ? 0 : 12,
|
||||
bottom ? bottomEdge - cardHeight : safeTop + 8,
|
||||
attached ? width : width - 24,
|
||||
cardHeight,
|
||||
);
|
||||
final rect = Rect.lerp(
|
||||
attached
|
||||
? Rect.fromLTWH(0, bottomEdge, width, 0)
|
||||
: origin ?? target,
|
||||
target,
|
||||
progress,
|
||||
)!;
|
||||
final radius = attached
|
||||
? BorderRadius.vertical(
|
||||
top: Radius.circular(NooRadii.card * progress),
|
||||
)
|
||||
: BorderRadius.circular(
|
||||
lerpDouble(
|
||||
(origin?.width ?? 32) / 2,
|
||||
NooRadii.card,
|
||||
progress,
|
||||
)!,
|
||||
);
|
||||
final avatarTarget = Rect.fromLTWH(
|
||||
target.right - 49,
|
||||
target.top + avatarOffset.dy,
|
||||
32,
|
||||
32,
|
||||
);
|
||||
final avatar = Rect.lerp(
|
||||
origin == null
|
||||
? avatarTarget
|
||||
: Rect.fromCenter(
|
||||
center: origin!.center,
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
avatarTarget,
|
||||
progress,
|
||||
)!;
|
||||
return Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
if (enabled)
|
||||
Positioned(
|
||||
left: attached ? 0 : 12,
|
||||
right: attached ? 0 : 12,
|
||||
top: safeTop,
|
||||
child: Offstage(
|
||||
child:
|
||||
NotificationListener<
|
||||
SizeChangedLayoutNotification
|
||||
>(
|
||||
onNotification: (_) {
|
||||
measure();
|
||||
return false;
|
||||
},
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: KeyedSubtree(
|
||||
key: measureKey,
|
||||
child: card(measurement: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (enabled && progress > 0 && origin != null) ...[
|
||||
Positioned.fill(
|
||||
bottom: bottom ? height - bottomEdge : 0,
|
||||
child: Semantics(
|
||||
label: 'Close menu',
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: close,
|
||||
child: const ColoredBox(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(
|
||||
rect: rect,
|
||||
child: _AvatarSurface(
|
||||
radius: radius,
|
||||
connected: attached,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: OverflowBox(
|
||||
alignment: bottom
|
||||
? Alignment.bottomRight
|
||||
: Alignment.topRight,
|
||||
minWidth: target.width,
|
||||
maxWidth: target.width,
|
||||
minHeight: target.height,
|
||||
maxHeight: target.height,
|
||||
child: IgnorePointer(
|
||||
ignoring: progress < .95,
|
||||
child: Opacity(
|
||||
opacity: ((progress - .3) / .7).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
),
|
||||
child: card(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!attached && progress < 1)
|
||||
Positioned.fromRect(
|
||||
rect: avatar,
|
||||
child: Semantics(
|
||||
label: 'Close menu',
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: close,
|
||||
child: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OtherAccountRow extends StatelessWidget {
|
||||
final String name;
|
||||
final String host;
|
||||
@@ -331,8 +730,7 @@ class _OtherAccountRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
||||
const SizedBox(width: 44),
|
||||
NooAvatar(initials: accountInitial(name), current: false, size: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import 'noo/nav/noo_bottom_bar.dart';
|
||||
import 'avatar_menu.dart';
|
||||
|
||||
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
|
||||
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
|
||||
@@ -16,19 +17,27 @@ import 'noo/nav/noo_bottom_bar.dart';
|
||||
class BottomNavBar extends StatelessWidget {
|
||||
final NooNavStyle style;
|
||||
final NooBottomBarStyle barStyle;
|
||||
final bool frosted;
|
||||
final double frostedBlur;
|
||||
final double frostedOpacity;
|
||||
final List<AppTab> tabs;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final VoidCallback? onSearchTap;
|
||||
final bool avatarInBottomBar;
|
||||
|
||||
const BottomNavBar({
|
||||
super.key,
|
||||
required this.style,
|
||||
this.barStyle = NooBottomBarStyle.attached,
|
||||
this.frosted = false,
|
||||
this.frostedBlur = NooBottomBar.defaultFrostedBlur,
|
||||
this.frostedOpacity = NooBottomBar.defaultFrostedOpacity,
|
||||
required this.tabs,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
this.onSearchTap,
|
||||
this.avatarInBottomBar = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -36,16 +45,35 @@ class BottomNavBar extends StatelessWidget {
|
||||
return NooBottomBar(
|
||||
style: style,
|
||||
barStyle: barStyle,
|
||||
frosted: frosted,
|
||||
frostedBlur: frostedBlur,
|
||||
frostedOpacity: frostedOpacity,
|
||||
destinations: [
|
||||
for (final tab in tabs)
|
||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||
],
|
||||
selectedIndex: selectedIndex,
|
||||
onSelected: onDestinationSelected,
|
||||
onSelected: (index) {
|
||||
AvatarNavigationScope.maybeOf(context)?.close();
|
||||
onDestinationSelected(index);
|
||||
},
|
||||
searchDestination: onSearchTap == null
|
||||
? null
|
||||
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
|
||||
onSearchTap: onSearchTap,
|
||||
onSearchTap: onSearchTap == null
|
||||
? null
|
||||
: () {
|
||||
AvatarNavigationScope.maybeOf(context)?.close();
|
||||
onSearchTap!();
|
||||
},
|
||||
avatarSatellite: avatarInBottomBar
|
||||
? AvatarNavigationSatellite(
|
||||
size: NooBottomBar.rowHeight(style, barStyle),
|
||||
)
|
||||
: null,
|
||||
avatarMenuProgress: avatarInBottomBar
|
||||
? AvatarNavigationScope.maybeOf(context)?.progress ?? 0
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_chip.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
import 'noo/core/noo_toggle.dart';
|
||||
import 'filter_mode_rows.dart';
|
||||
import 'noo/lists/noo_grouped_list.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/overlays/noo_sheet.dart';
|
||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
||||
final files = context.watch<FilesController>();
|
||||
final filtersActive =
|
||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||
files.showHiddenFiles ||
|
||||
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||
|
||||
return SizedBox(
|
||||
@@ -171,54 +171,46 @@ class FilesControlsRow extends StatelessWidget {
|
||||
// Same "icon always, label only when selected" pill as
|
||||
// Photos' own type filter below - not a checkmark list, so
|
||||
// the two screens' filter sheets look and behave the same.
|
||||
NooSegmentedControl<FilesTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: files.filesTypeFilter,
|
||||
onChanged: files.setFilesTypeFilter,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.filesOnly,
|
||||
icon: LucideIcons.file,
|
||||
label: 'Files',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.foldersOnly,
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Folders',
|
||||
),
|
||||
],
|
||||
FilterSection(
|
||||
icon: LucideIcons.layoutGrid,
|
||||
title: 'Type',
|
||||
child: NooSegmentedControl<FilesTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: files.filesTypeFilter,
|
||||
onChanged: files.setFilesTypeFilter,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.filesOnly,
|
||||
icon: LucideIcons.file,
|
||||
label: 'Files',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.foldersOnly,
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Folders',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: files.showHiddenFiles,
|
||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
||||
),
|
||||
),
|
||||
if (showStorageScope)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
HiddenFilesFilterRow(
|
||||
value: files.hiddenFilter,
|
||||
onChanged: files.setHiddenFilter,
|
||||
),
|
||||
if (showStorageScope) ...[
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
|
||||
/// "Hidden files" row of the Files/Photos filter sheets: hide them (the
|
||||
/// default), show only them, or show them alongside everything else. Same
|
||||
/// icon-always/label-when-selected pill as the type filter above it in
|
||||
/// both sheets.
|
||||
class HiddenFilesFilterRow extends StatelessWidget {
|
||||
final HiddenFilesFilter value;
|
||||
final ValueChanged<HiddenFilesFilter> onChanged;
|
||||
|
||||
const HiddenFilesFilterRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FilterModeRow<HiddenFilesFilter>(
|
||||
icon: LucideIcons.eye,
|
||||
title: 'Hidden files',
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.hide,
|
||||
icon: LucideIcons.eyeOff,
|
||||
label: 'Hide',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.only,
|
||||
icon: LucideIcons.eye,
|
||||
label: 'Only hidden',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.include,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "External storage" row of the Files/Photos filter sheets: cloud only
|
||||
/// (the default), only external storage, or both. Files shows the external
|
||||
/// items in a collapsible section for [StorageScope.all].
|
||||
class StorageScopeRow extends StatelessWidget {
|
||||
final StorageScope value;
|
||||
final ValueChanged<StorageScope> onChanged;
|
||||
|
||||
const StorageScopeRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FilterModeRow<StorageScope>(
|
||||
icon: LucideIcons.hardDrive,
|
||||
title: 'External storage',
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: StorageScope.cloud,
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'Cloud only',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: StorageScope.external,
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: 'Only external',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: StorageScope.all,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon + title line above [child] - the shape every row of the Files/
|
||||
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||
/// control, which lives in its own file.
|
||||
class FilterSection extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const FilterSection({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterModeRow<T> extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
final List<NooSegmentOption<T>> options;
|
||||
|
||||
const _FilterModeRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterSection(
|
||||
icon: icon,
|
||||
title: title,
|
||||
child: NooSegmentedControl<T>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: options,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/settings_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
/// The blurred/translucent chrome for the media viewer's overlay bars
|
||||
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
|
||||
/// transport row) - the one deliberate exception to the design system's
|
||||
/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome
|
||||
/// floats over photo/video content rather than over the app's own
|
||||
/// surfaces. No shadow, no outline: [color] defaults to the app's own
|
||||
/// `surface` token (so this panel reads as light or dark to match the
|
||||
/// active theme, like the rest of the media viewer, instead of a fixed
|
||||
/// dark tone regardless of theme) at a higher-than-usual [opacity], since
|
||||
/// a light tint needs denser coverage than a near-black one did to stay
|
||||
/// legible over arbitrary photo/video brightness underneath.
|
||||
/// Media viewer chrome that shares the navigation frost preference.
|
||||
/// Turning frosting off produces an opaque themed surface without blur.
|
||||
class FrostedGlassContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double borderRadius;
|
||||
@@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
||||
final BorderRadius? radius;
|
||||
|
||||
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
||||
final double blurSigma;
|
||||
/// Blur sigma for the backdrop filter. Defaults to the shared frost setting.
|
||||
final double? blurSigma;
|
||||
|
||||
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed
|
||||
/// value; pass an explicit value (e.g. from user settings) to make it
|
||||
/// adjustable.
|
||||
final double opacity;
|
||||
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting.
|
||||
final double? opacity;
|
||||
final bool? frosted;
|
||||
|
||||
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
|
||||
/// build time, so it always matches the active theme) - pass an explicit
|
||||
@@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
required this.child,
|
||||
this.borderRadius = 0,
|
||||
this.radius,
|
||||
this.blurSigma = 20,
|
||||
this.opacity = 0.8,
|
||||
this.blurSigma,
|
||||
this.opacity,
|
||||
this.frosted,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||
final tint = color ?? context.nooColors.surface;
|
||||
final settings = context.watch<SettingsController?>();
|
||||
final enabled = frosted ?? settings?.bottomBarFrosted ?? false;
|
||||
final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20;
|
||||
final fill = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? tint.withValues(
|
||||
alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72,
|
||||
)
|
||||
: tint,
|
||||
borderRadius: shape,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
return ClipRRect(
|
||||
borderRadius: shape,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: opacity),
|
||||
borderRadius: shape,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: enabled
|
||||
? BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||
child: fill,
|
||||
)
|
||||
: fill,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger }
|
||||
enum NooButtonVariant {
|
||||
primary,
|
||||
tonal,
|
||||
secondary,
|
||||
danger,
|
||||
outline,
|
||||
text,
|
||||
textDanger,
|
||||
}
|
||||
|
||||
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
|
||||
|
||||
@@ -37,6 +45,14 @@ class NooButton extends StatefulWidget {
|
||||
final bool disabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// A round, icon-only button (no label) - [icon] centred in a circle as
|
||||
/// wide as the size's height, instead of the pill's side padding. Callers
|
||||
/// are responsible for a tooltip/semantic label, since there's no text.
|
||||
final bool iconOnly;
|
||||
|
||||
/// Continuous icon-only to labelled expansion for animated shell actions.
|
||||
final double? expansion;
|
||||
|
||||
const NooButton({
|
||||
super.key,
|
||||
this.variant = NooButtonVariant.primary,
|
||||
@@ -46,6 +62,8 @@ class NooButton extends StatefulWidget {
|
||||
this.fullWidth = false,
|
||||
this.disabled = false,
|
||||
this.onTap,
|
||||
this.iconOnly = false,
|
||||
this.expansion,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -87,17 +105,29 @@ class _NooButtonState extends State<NooButton> {
|
||||
final colors = context.nooColors;
|
||||
final s = _sizes[widget.size]!;
|
||||
final (bg, fg, border) = _colors(colors);
|
||||
final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant;
|
||||
final hasLeadingIcon =
|
||||
widget.icon != null && widget.child != null && !_isTextVariant;
|
||||
|
||||
final content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
|
||||
if (widget.icon != null && widget.child != null) SizedBox(width: s.gap),
|
||||
if (widget.child != null)
|
||||
DefaultTextStyle(
|
||||
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||
child: widget.child!,
|
||||
if (widget.icon != null && widget.child != null)
|
||||
SizedBox(width: s.gap * (widget.expansion ?? 1)),
|
||||
if (widget.child != null && widget.expansion != 0)
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: widget.expansion ?? 1,
|
||||
heightFactor: 1,
|
||||
child: Opacity(
|
||||
opacity: widget.expansion ?? 1,
|
||||
child: DefaultTextStyle(
|
||||
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||
child: widget.child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -107,7 +137,9 @@ class _NooButtonState extends State<NooButton> {
|
||||
child: GestureDetector(
|
||||
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
||||
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
|
||||
onTapCancel: widget.disabled ? null : () => setState(() => _down = false),
|
||||
onTapCancel: widget.disabled
|
||||
? null
|
||||
: () => setState(() => _down = false),
|
||||
onTap: widget.disabled ? null : widget.onTap,
|
||||
child: AnimatedScale(
|
||||
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
|
||||
@@ -115,8 +147,20 @@ class _NooButtonState extends State<NooButton> {
|
||||
curve: NooMotion.ease,
|
||||
child: Container(
|
||||
height: s.height,
|
||||
width: widget.fullWidth ? double.infinity : null,
|
||||
padding: _isTextVariant
|
||||
width: widget.iconOnly
|
||||
? s.height
|
||||
: (widget.fullWidth ? double.infinity : null),
|
||||
padding: widget.expansion != null
|
||||
? EdgeInsets.only(
|
||||
left:
|
||||
(s.height - s.icon) / 2 +
|
||||
(s.px - 4 - (s.height - s.icon) / 2) *
|
||||
widget.expansion!,
|
||||
right:
|
||||
(s.height - s.icon) / 2 +
|
||||
(s.px - (s.height - s.icon) / 2) * widget.expansion!,
|
||||
)
|
||||
: _isTextVariant || widget.iconOnly
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(
|
||||
left: hasLeadingIcon ? s.px - 4 : s.px,
|
||||
@@ -128,7 +172,9 @@ class _NooButtonState extends State<NooButton> {
|
||||
// of shrink-wrapping to it, which is what stretched this button
|
||||
// edge-to-edge instead of sizing to its content (see NooFab's
|
||||
// doc comment for the same bug).
|
||||
alignment: widget.fullWidth ? Alignment.center : null,
|
||||
alignment: widget.fullWidth || widget.iconOnly
|
||||
? Alignment.center
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// A continuous (or [divisions]-stepped) value slider in the design system's
|
||||
/// colors: accent fill on a `surface3` track, accent thumb. Data-agnostic -
|
||||
/// the caller owns [value] and formats any readout beside it.
|
||||
class NooSlider extends StatelessWidget {
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final int? divisions;
|
||||
final ValueChanged<double>? onChanged;
|
||||
final String? semanticLabel;
|
||||
|
||||
const NooSlider({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
this.divisions,
|
||||
this.onChanged,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 4,
|
||||
activeTrackColor: colors.accent,
|
||||
inactiveTrackColor: colors.surface3,
|
||||
thumbColor: colors.accent,
|
||||
overlayColor: colors.accent.withValues(alpha: 0.12),
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9),
|
||||
trackShape: const RoundedRectSliderTrackShape(),
|
||||
showValueIndicator: ShowValueIndicator.never,
|
||||
),
|
||||
child: Slider(
|
||||
value: value.clamp(min, max),
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
semanticFormatterCallback: semanticLabel == null
|
||||
? null
|
||||
: (v) => '$semanticLabel ${v.toStringAsFixed(2)}',
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ const int _kDesktopInlineActions = 4;
|
||||
/// Replaces the bare row of Material `IconButton`s each screen used to
|
||||
/// build ad hoc.
|
||||
///
|
||||
/// A `surface` card (radius 20) - selection reads as a distinct mode, not
|
||||
/// A floating `surface` card (radius 20), with the bottom bar's border
|
||||
/// and shadow - selection reads as a distinct mode, not
|
||||
/// just a row of buttons floating on `bg` - with a close circle, the
|
||||
/// count, and the bulk actions. This sits in the *content* pane's own
|
||||
/// pinned header slot on every platform (the shell's desktop toolbar,
|
||||
@@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
border: Border.all(color: colors.line),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget {
|
||||
final IconData? icon;
|
||||
final Widget label;
|
||||
final Widget? subtitle;
|
||||
|
||||
/// Renders as `value` text + a trailing chevron - mutually exclusive with
|
||||
/// [trailing] (a `NooToggle`, button, badge, etc.).
|
||||
final String? value;
|
||||
final Widget? trailing;
|
||||
final bool danger;
|
||||
|
||||
/// Accent-text action row, e.g. "Add account".
|
||||
final bool accent;
|
||||
final VoidCallback? onTap;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const NooSettingsRow({
|
||||
super.key,
|
||||
@@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget {
|
||||
this.danger = false,
|
||||
this.accent = false,
|
||||
this.onTap,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1);
|
||||
final color = danger
|
||||
? colors.danger
|
||||
: (accent ? colors.accentText : colors.fg1);
|
||||
final minHeight = subtitle != null ? 60.0 : 52.0;
|
||||
|
||||
Widget? trail = trailing;
|
||||
@@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
value!,
|
||||
style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3),
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: 15,
|
||||
height: 1,
|
||||
color: colors.fg3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
|
||||
@@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
color: backgroundColor ?? colors.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
@@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20, color: danger || accent ? color : colors.fg2),
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: danger || accent ? color : colors.fg2,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
Expanded(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
import 'noo_nav_style.dart';
|
||||
@@ -18,14 +19,17 @@ const double _kAndroidPillHeight = 32;
|
||||
/// what order, is the caller's business; this only draws [destinations].
|
||||
///
|
||||
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
|
||||
/// padding) of icon 24 over a 10px label. Active: accent-text, 600.
|
||||
/// Idle: fg-3, 500. No ripple.
|
||||
/// padding) of icon 24 over a 10px selected label. Active: accent-text, 600.
|
||||
/// Idle: centered fg-3 icon, no label. No ripple.
|
||||
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
|
||||
/// icon over a 12px label (label only visible - not removed from layout,
|
||||
/// so the row never resizes - once selected). The active icon sits in a
|
||||
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
||||
/// rather than popping in/out on the destination item itself.
|
||||
///
|
||||
/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android
|
||||
/// row below, so iOS gets the same icon-only idle tabs.
|
||||
///
|
||||
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||
@@ -45,6 +49,13 @@ const double _kAndroidPillHeight = 32;
|
||||
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||
/// `bottomBarClearance`.
|
||||
///
|
||||
/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a
|
||||
/// translucent one over a backdrop blur, on both bar styles and both nav
|
||||
/// styles, and drops [nooDialogShadow] (a shadow would show through the
|
||||
/// glass). It only reads as glass if content draws behind the bar, so the
|
||||
/// host `Scaffold` needs `extendBody: true` and each scrollable body needs
|
||||
/// the same trailing clearance floating does - see [drawsBehindBody].
|
||||
///
|
||||
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||
@@ -54,31 +65,59 @@ const double _kAndroidPillHeight = 32;
|
||||
class NooBottomBar extends StatelessWidget {
|
||||
final NooNavStyle style;
|
||||
final NooBottomBarStyle barStyle;
|
||||
final bool frosted;
|
||||
|
||||
/// Backdrop blur sigma and surface-fill opacity while [frosted] (user-
|
||||
/// adjustable in Settings; the defaults match [FrostedGlassContainer]'s
|
||||
/// blur).
|
||||
final double frostedBlur;
|
||||
final double frostedOpacity;
|
||||
final List<NooNavDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final NooNavDestination? searchDestination;
|
||||
final VoidCallback? onSearchTap;
|
||||
|
||||
/// Bottom-avatar navigation occupies this slot while closed. Search moves
|
||||
/// from the row into the slot as the avatar expands into its popup.
|
||||
final Widget? avatarSatellite;
|
||||
final double avatarMenuProgress;
|
||||
|
||||
const NooBottomBar({
|
||||
super.key,
|
||||
required this.style,
|
||||
this.barStyle = NooBottomBarStyle.attached,
|
||||
this.frosted = false,
|
||||
this.frostedBlur = defaultFrostedBlur,
|
||||
this.frostedOpacity = defaultFrostedOpacity,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
this.searchDestination,
|
||||
this.onSearchTap,
|
||||
this.avatarSatellite,
|
||||
this.avatarMenuProgress = 0,
|
||||
});
|
||||
|
||||
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
||||
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||
/// much clearance (see the class doc comment) instead of guessing.
|
||||
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
||||
if (style == NooNavStyle.ios) return 50;
|
||||
if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) {
|
||||
return 50;
|
||||
}
|
||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||
}
|
||||
|
||||
static const double defaultFrostedBlur = 20;
|
||||
static const double defaultFrostedOpacity = 0.72;
|
||||
|
||||
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||
/// frosted bar (there's nothing to blur otherwise).
|
||||
static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) =>
|
||||
frosted || barStyle == NooBottomBarStyle.floating;
|
||||
|
||||
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||
static const double floatingBottomMargin = 12;
|
||||
@@ -86,13 +125,20 @@ class NooBottomBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final ios = style == NooNavStyle.ios;
|
||||
final floating = barStyle == NooBottomBarStyle.floating;
|
||||
// Floating always gets the Android row (icon-only idle tabs, sliding
|
||||
// pill) - iOS's icon-over-label row doesn't fit a pill that short.
|
||||
final ios = style == NooNavStyle.ios && !floating;
|
||||
final hasSearch = searchDestination != null && onSearchTap != null;
|
||||
// Attached folds Search into the row itself (last item); floating
|
||||
// gives it a separate satellite circle instead (built below), so the
|
||||
// row builders only ever see it as a trailing item in the former case.
|
||||
final rowSearch = hasSearch && !floating ? searchDestination : null;
|
||||
final rowSearch =
|
||||
hasSearch &&
|
||||
(!floating || avatarSatellite != null) &&
|
||||
(avatarSatellite == null || !floating || avatarMenuProgress < 1)
|
||||
? searchDestination
|
||||
: null;
|
||||
|
||||
final row = ios
|
||||
? _buildIosRow(trailingSearch: rowSearch)
|
||||
@@ -104,17 +150,21 @@ class NooBottomBar extends StatelessWidget {
|
||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||
|
||||
if (floating) {
|
||||
final pill = Container(
|
||||
final pillRadius = BorderRadius.circular(28);
|
||||
final pillBox = Container(
|
||||
height: barHeight,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
color: _fill(colors),
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
borderRadius: pillRadius,
|
||||
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||
),
|
||||
child: row,
|
||||
);
|
||||
final pill = frosted
|
||||
? _blurred(pillBox, pillRadius, frostedBlur)
|
||||
: pillBox;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
@@ -124,7 +174,9 @@ class NooBottomBar extends StatelessWidget {
|
||||
16,
|
||||
NooBottomBar.floatingBottomMargin,
|
||||
),
|
||||
child: hasSearch
|
||||
child: avatarSatellite != null
|
||||
? _withAvatar(pill, barHeight, hasSearch)
|
||||
: hasSearch
|
||||
// A fixed-height SizedBox, not just a Row with
|
||||
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
||||
// gives this widget a *loose* (unbounded-max) height
|
||||
@@ -145,6 +197,9 @@ class NooBottomBar extends StatelessWidget {
|
||||
destination: searchDestination!,
|
||||
onTap: onSearchTap!,
|
||||
size: barHeight,
|
||||
frosted: frosted,
|
||||
frostedBlur: frostedBlur,
|
||||
frostedOpacity: frostedOpacity,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -154,39 +209,133 @@ class NooBottomBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
final bar = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
color: _fill(colors),
|
||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(height: barHeight, child: row),
|
||||
child: SizedBox(
|
||||
height: barHeight,
|
||||
child: avatarSatellite == null
|
||||
? row
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: row),
|
||||
SizedBox(
|
||||
width: barHeight,
|
||||
height: barHeight,
|
||||
child: avatarSatellite,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar;
|
||||
}
|
||||
|
||||
Widget _withAvatar(Widget rail, double height, bool hasSearch) {
|
||||
final satelliteWidth = hasSearch
|
||||
? height
|
||||
: height * (1 - avatarMenuProgress);
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: rail),
|
||||
SizedBox(width: 8 * (hasSearch ? 1 : 1 - avatarMenuProgress)),
|
||||
SizedBox(
|
||||
width: satelliteWidth,
|
||||
height: height,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerRight,
|
||||
minWidth: height,
|
||||
maxWidth: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
avatarSatellite!,
|
||||
if (hasSearch && avatarMenuProgress > 0)
|
||||
Opacity(
|
||||
opacity: avatarMenuProgress,
|
||||
child: _SearchSatellite(
|
||||
destination: searchDestination!,
|
||||
onTap: onSearchTap!,
|
||||
size: height,
|
||||
frosted: frosted,
|
||||
frostedBlur: frostedBlur,
|
||||
frostedOpacity: frostedOpacity,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _fill(NooColors colors) => frosted
|
||||
? colors.surface.withValues(alpha: frostedOpacity)
|
||||
: colors.surface;
|
||||
|
||||
double get _rowSearchFraction =>
|
||||
avatarSatellite == null || barStyle == NooBottomBarStyle.attached
|
||||
? 1
|
||||
: 1 - avatarMenuProgress;
|
||||
|
||||
Widget _searchRowItem(Widget child, double width) => SizedBox(
|
||||
width: width * _rowSearchFraction,
|
||||
child: IgnorePointer(
|
||||
ignoring: _rowSearchFraction < .95,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
minWidth: width,
|
||||
maxWidth: width,
|
||||
child: Opacity(opacity: _rowSearchFraction, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
Expanded(
|
||||
child: _IosItem(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
onTap: () => onSelected(i),
|
||||
),
|
||||
),
|
||||
if (trailingSearch != null)
|
||||
Expanded(
|
||||
child: _IosItem(
|
||||
destination: trailingSearch,
|
||||
selected: false,
|
||||
onTap: onSearchTap!,
|
||||
),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width =
|
||||
constraints.maxWidth /
|
||||
(destinations.length +
|
||||
(trailingSearch == null ? 0 : _rowSearchFraction));
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: _IosItem(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
selectedLabelOnly: true,
|
||||
onTap: () => onSelected(i),
|
||||
),
|
||||
),
|
||||
if (trailingSearch != null)
|
||||
_searchRowItem(
|
||||
_IosItem(
|
||||
destination: trailingSearch,
|
||||
selected: false,
|
||||
selectedLabelOnly: true,
|
||||
onTap: onSearchTap!,
|
||||
),
|
||||
width,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,7 +345,8 @@ class NooBottomBar extends StatelessWidget {
|
||||
NooNavDestination? trailingSearch,
|
||||
}) {
|
||||
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
||||
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
|
||||
final itemCount =
|
||||
destinations.length + (trailingSearch != null ? _rowSearchFraction : 0);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final itemWidth = constraints.maxWidth / itemCount;
|
||||
@@ -222,7 +372,8 @@ class NooBottomBar extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
Expanded(
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: _AndroidItem(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
@@ -231,13 +382,14 @@ class NooBottomBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (trailingSearch != null)
|
||||
Expanded(
|
||||
child: _AndroidItem(
|
||||
_searchRowItem(
|
||||
_AndroidItem(
|
||||
destination: trailingSearch,
|
||||
selected: false,
|
||||
floating: floating,
|
||||
onTap: onSearchTap!,
|
||||
),
|
||||
itemWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -248,6 +400,16 @@ class NooBottomBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
||||
Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
/// The floating bar's own Search entry point, next to the pill rather than
|
||||
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||
/// same fill/border as the pill so the two still read as one family.
|
||||
@@ -255,11 +417,17 @@ class _SearchSatellite extends StatelessWidget {
|
||||
final NooNavDestination destination;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
final bool frosted;
|
||||
final double frostedBlur;
|
||||
final double frostedOpacity;
|
||||
|
||||
const _SearchSatellite({
|
||||
required this.destination,
|
||||
required this.onTap,
|
||||
required this.size,
|
||||
required this.frosted,
|
||||
required this.frostedBlur,
|
||||
required this.frostedOpacity,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -271,31 +439,42 @@ class _SearchSatellite extends StatelessWidget {
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [nooDialogShadow],
|
||||
),
|
||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||
),
|
||||
child: _satelliteBody(colors),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _satelliteBody(NooColors colors) {
|
||||
final disc = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: frosted
|
||||
? colors.surface.withValues(alpha: frostedOpacity)
|
||||
: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||
),
|
||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||
);
|
||||
return frosted
|
||||
? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur)
|
||||
: disc;
|
||||
}
|
||||
}
|
||||
|
||||
class _IosItem extends StatelessWidget {
|
||||
final NooNavDestination destination;
|
||||
final bool selected;
|
||||
final bool selectedLabelOnly;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _IosItem({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
this.selectedLabelOnly = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@@ -306,30 +485,35 @@ class _IosItem extends StatelessWidget {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: selectedLabelOnly && !selected ? destination.label : null,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(destination.icon, size: 24, color: fg),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.navLabel.copyWith(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: fg,
|
||||
child: selectedLabelOnly && !selected
|
||||
? Center(child: Icon(destination.icon, size: 24, color: fg))
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(destination.icon, size: 24, color: fg),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.navLabel.copyWith(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating }
|
||||
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
||||
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
||||
/// drops that icon entirely and repurposes the avatar button - already
|
||||
/// sitting top-right, already a tap target every user already knows - as
|
||||
/// the one entry point instead, opening a dropdown anchored below it (see
|
||||
/// available at the top or bottom by user preference - as the one entry
|
||||
/// point instead, expanding into an anchored navigation card (see
|
||||
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
||||
/// sliding from the opposite edge. Applies on both platforms, since
|
||||
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
||||
|
||||
@@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
|
||||
/// Android) through [IconTheme].
|
||||
final List<Widget> actions;
|
||||
final bool iosActionsInTitleRow;
|
||||
|
||||
/// iOS only: the search field shown below the large title.
|
||||
final Widget? search;
|
||||
@@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
this.leading,
|
||||
this.onMenu,
|
||||
this.actions = const [],
|
||||
this.iosActionsInTitleRow = false,
|
||||
this.search,
|
||||
this.androidTitleTrailing,
|
||||
this.backgroundColor,
|
||||
@@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@override
|
||||
Size get preferredSize {
|
||||
if (!_ios) return const Size.fromHeight(_androidRow);
|
||||
if (iosActionsInTitleRow) {
|
||||
return Size.fromHeight(
|
||||
64 + (search != null ? _iosSearch + _iosSearchBottom : 0),
|
||||
);
|
||||
}
|
||||
return Size.fromHeight(
|
||||
_iosRow +
|
||||
_iosTitleTop +
|
||||
@@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
Widget _buildIos(NooColors colors, Widget? lead) {
|
||||
if (iosActionsInTitleRow) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 64,
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: lead == null ? 16 : 6,
|
||||
end: 6,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
?lead,
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.largeTitle.copyWith(
|
||||
leadingDistribution: TextLeadingDistribution.even,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (search != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.md,
|
||||
0,
|
||||
NooSpace.md,
|
||||
_iosSearchBottom,
|
||||
),
|
||||
child: SizedBox(height: _iosSearch, child: search),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Pins selection actions above the shell navigation. Scaffold supplies its
|
||||
/// navigation footprint through MediaQuery padding when the body extends
|
||||
/// behind navigation; otherwise the body already ends above it.
|
||||
class SelectionBarOverlay extends StatelessWidget {
|
||||
static const clearance = 64.0;
|
||||
|
||||
final Widget child;
|
||||
final Widget? bar;
|
||||
|
||||
const SelectionBarOverlay({super.key, required this.child, this.bar});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bar == null) return child;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.paddingOf(context).bottom + 8,
|
||||
child: SizedBox(height: 56, child: bar),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import '../../models/selection_action.dart';
|
||||
import '../../providers/settings_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_tab_order_row.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/core/noo_segmented_control.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section: the priority order bulk actions (favorite, share,
|
||||
/// download, ...) show in on the Files/Photos/Favorites multi-select
|
||||
@@ -13,7 +15,7 @@ import 'settings_section.dart';
|
||||
/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
|
||||
/// desktop, see its own doc comment) vs. behind its trailing "More" -
|
||||
/// there's no pin/hide concept here the way Tabs has, so this is just a
|
||||
/// reorder list, not a whole section of its own option rows.
|
||||
/// position choice followed by a reorder list.
|
||||
class SettingsActionBarSection extends StatelessWidget {
|
||||
const SettingsActionBarSection({super.key});
|
||||
|
||||
@@ -33,18 +35,30 @@ class SettingsActionBarSection extends StatelessWidget {
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
final orderList = _ActionOrderList(
|
||||
rounded: desktop,
|
||||
order: settings.selectionActionOrder,
|
||||
onReorder: (oldIndex, newIndex) =>
|
||||
_handleReorder(settings, oldIndex, newIndex),
|
||||
);
|
||||
|
||||
final positionRow = NooSettingsRow(
|
||||
label: const Text('Position'),
|
||||
trailing: NooSegmentedControl<SelectionBarPosition>(
|
||||
onSurface: true,
|
||||
value: settings.selectionBarPosition,
|
||||
onChanged: settings.setSelectionBarPosition,
|
||||
options: const [
|
||||
NooSegmentOption(value: SelectionBarPosition.top, label: 'Top'),
|
||||
NooSegmentOption(value: SelectionBarPosition.bottom, label: 'Bottom'),
|
||||
],
|
||||
),
|
||||
);
|
||||
final colors = context.nooColors;
|
||||
|
||||
if (!desktop) {
|
||||
return SettingsSection(
|
||||
title: 'Action bar',
|
||||
subtitle: _subtitle,
|
||||
children: [orderList],
|
||||
return NooGroupedList(
|
||||
footer: const Text(_subtitle),
|
||||
children: [positionRow, orderList],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
positionRow,
|
||||
const SizedBox(height: NooSpace.md),
|
||||
orderList,
|
||||
],
|
||||
),
|
||||
@@ -79,8 +95,13 @@ class SettingsActionBarSection extends StatelessWidget {
|
||||
class _ActionOrderList extends StatelessWidget {
|
||||
final List<SelectionActionKind> order;
|
||||
final void Function(int oldIndex, int newIndex) onReorder;
|
||||
final bool rounded;
|
||||
|
||||
const _ActionOrderList({required this.order, required this.onReorder});
|
||||
const _ActionOrderList({
|
||||
required this.order,
|
||||
required this.onReorder,
|
||||
required this.rounded,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -105,12 +126,14 @@ class _ActionOrderList extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
);
|
||||
final rows = DecoratedBox(
|
||||
decoration: BoxDecoration(color: colors.line),
|
||||
child: list,
|
||||
);
|
||||
if (!rounded) return rows;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: colors.line),
|
||||
child: list,
|
||||
),
|
||||
child: rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_segmented_control.dart';
|
||||
import '../noo/core/noo_slider.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/nav/noo_nav_style.dart';
|
||||
@@ -53,7 +54,36 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
children: [
|
||||
_ThemeRow(settings: settings),
|
||||
_BottomBarStyleRow(settings: settings),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.glassWater,
|
||||
label: const Text('Frosted glass'),
|
||||
subtitle: const Text(
|
||||
'Blurred bottom navigation, avatar menus and media viewer panels',
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.bottomBarFrosted,
|
||||
onChanged: settings.setBottomBarFrosted,
|
||||
),
|
||||
),
|
||||
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||
_NavMenuStyleRow(settings: settings),
|
||||
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.userRound,
|
||||
label: const Text('Avatar position'),
|
||||
trailing: NooSegmentedControl<AvatarPosition>(
|
||||
onSurface: true,
|
||||
value: settings.avatarPosition,
|
||||
onChanged: settings.setAvatarPosition,
|
||||
options: const [
|
||||
NooSegmentOption(value: AvatarPosition.top, label: 'Top'),
|
||||
NooSegmentOption(
|
||||
value: AvatarPosition.bottom,
|
||||
label: 'Bottom',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.search,
|
||||
label: const Text('Search in bottom bar'),
|
||||
@@ -83,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
icon: LucideIcons.palette,
|
||||
label: const Text('Accent color'),
|
||||
subtitle: Text(
|
||||
settings.useDynamicColor
|
||||
settings.useDynamicColor &&
|
||||
Theme.of(context).platform != TargetPlatform.iOS
|
||||
? 'Matching your wallpaper'
|
||||
: settings.seedColor == AppTheme.defaultAccent
|
||||
? 'Default'
|
||||
: 'Custom color',
|
||||
),
|
||||
trailing: _AccentSwatchDot(
|
||||
color: settings.useDynamicColor ? null : settings.seedColor,
|
||||
color:
|
||||
settings.useDynamicColor &&
|
||||
Theme.of(context).platform != TargetPlatform.iOS
|
||||
? null
|
||||
: settings.seedColor,
|
||||
),
|
||||
onTap: () => _openAccentPicker(context, settings),
|
||||
),
|
||||
@@ -103,15 +138,16 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
onChanged: settings.setAmoledDark,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.waves,
|
||||
label: const Text('Seek bar style'),
|
||||
subtitle: const Text(
|
||||
'The progress bar style used when playing videos',
|
||||
if (Theme.of(context).platform != TargetPlatform.iOS)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.waves,
|
||||
label: const Text('Seek bar style'),
|
||||
subtitle: const Text(
|
||||
'The progress bar style used when playing videos',
|
||||
),
|
||||
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
|
||||
onTap: () => _openSeekBarPicker(context, settings),
|
||||
),
|
||||
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
|
||||
onTap: () => _openSeekBarPicker(context, settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -155,6 +191,7 @@ class _ThemeRow extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<ThemeMode>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
value: settings.themeMode,
|
||||
onChanged: settings.setThemeMode,
|
||||
options: const [
|
||||
@@ -218,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<NooBottomBarStyle>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
value: settings.bottomBarStyle,
|
||||
onChanged: settings.setBottomBarStyle,
|
||||
options: const [
|
||||
@@ -240,6 +278,118 @@ class _BottomBarStyleRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// How strong the frosted-glass bottom bar looks: three presets for a quick
|
||||
/// pick, and the blur / opacity sliders they set, for anything in between.
|
||||
/// Only shown while frosted glass is on.
|
||||
class _FrostedGlassRow extends StatelessWidget {
|
||||
final SettingsController settings;
|
||||
|
||||
const _FrostedGlassRow({required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 20, color: colors.fg2),
|
||||
const SizedBox(width: 14),
|
||||
Text(
|
||||
'Frost strength',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<FrostedGlassPreset?>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
// No segment selected once the sliders no longer match a preset.
|
||||
value: settings.frostedPreset,
|
||||
onChanged: (preset) {
|
||||
if (preset != null) settings.setFrostedPreset(preset);
|
||||
},
|
||||
options: [
|
||||
for (final preset in FrostedGlassPreset.values)
|
||||
NooSegmentOption<FrostedGlassPreset?>(
|
||||
value: preset,
|
||||
label: preset.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SliderLine(
|
||||
label: 'Blur',
|
||||
readout: settings.bottomBarFrostedBlur.round().toString(),
|
||||
slider: NooSlider(
|
||||
value: settings.bottomBarFrostedBlur,
|
||||
min: minFrostedBlur,
|
||||
max: maxFrostedBlur,
|
||||
semanticLabel: 'Blur',
|
||||
onChanged: settings.setFrostedBlur,
|
||||
),
|
||||
),
|
||||
_SliderLine(
|
||||
label: 'Opacity',
|
||||
readout: '${(settings.bottomBarFrostedOpacity * 100).round()}%',
|
||||
slider: NooSlider(
|
||||
value: settings.bottomBarFrostedOpacity,
|
||||
min: minFrostedOpacity,
|
||||
max: maxFrostedOpacity,
|
||||
semanticLabel: 'Opacity',
|
||||
onChanged: settings.setFrostedOpacity,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliderLine extends StatelessWidget {
|
||||
final String label;
|
||||
final String readout;
|
||||
final Widget slider;
|
||||
|
||||
const _SliderLine({
|
||||
required this.label,
|
||||
required this.readout,
|
||||
required this.slider,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(label, style: NooText.body.copyWith(color: colors.fg2)),
|
||||
),
|
||||
Expanded(child: slider),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Text(
|
||||
readout,
|
||||
textAlign: TextAlign.end,
|
||||
style: NooText.body.copyWith(color: colors.fg3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
||||
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
||||
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
||||
@@ -278,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<NooNavMenuStyle>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
value: settings.navMenuStyle,
|
||||
onChanged: settings.setNavMenuStyle,
|
||||
options: const [
|
||||
@@ -331,6 +482,7 @@ class _FabStyleRow extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<FabStyle>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
value: settings.fabStyle,
|
||||
onChanged: settings.setFabStyle,
|
||||
options: const [
|
||||
@@ -384,22 +536,26 @@ class _AccentSwatchDot extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
||||
final useDynamicColor =
|
||||
settings.useDynamicColor &&
|
||||
Theme.of(context).platform != TargetPlatform.iOS;
|
||||
Widget grid(NooColors colors) => Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 14,
|
||||
children: [
|
||||
_AccentSwatch(
|
||||
isSelected: settings.useDynamicColor,
|
||||
onTap: () {
|
||||
settings.setUseDynamicColor(true);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
background: colors.surface3,
|
||||
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
|
||||
),
|
||||
if (Theme.of(context).platform != TargetPlatform.iOS)
|
||||
_AccentSwatch(
|
||||
isSelected: settings.useDynamicColor,
|
||||
onTap: () {
|
||||
settings.setUseDynamicColor(true);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
background: colors.surface3,
|
||||
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
|
||||
),
|
||||
for (final color in AppTheme.seedColors)
|
||||
_AccentSwatch(
|
||||
isSelected: !settings.useDynamicColor && settings.seedColor == color,
|
||||
isSelected: !useDynamicColor && settings.seedColor == color,
|
||||
onTap: () {
|
||||
settings.setSeedColor(color);
|
||||
Navigator.pop(context);
|
||||
|
||||
@@ -125,12 +125,16 @@ class _CacheIntervalRow extends StatelessWidget {
|
||||
onChanged: (value) => onChanged(value.round()),
|
||||
),
|
||||
),
|
||||
if (minutes < 15)
|
||||
if (Theme.of(context).platform == TargetPlatform.iOS ||
|
||||
(Theme.of(context).platform == TargetPlatform.android && minutes < 15))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'While the app is closed, synced files refresh at most every '
|
||||
'15 min (an Android limit).',
|
||||
Theme.of(context).platform == TargetPlatform.iOS
|
||||
? 'iOS decides when background sync runs; the refresh '
|
||||
'interval is a request, not a guaranteed schedule.'
|
||||
: 'While the app is closed, synced files refresh at most every '
|
||||
'15 min (an Android limit).',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,26 +26,28 @@ String biometricLabel(TargetPlatform platform) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 3: login lock, which gates opening the app, switching
|
||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
||||
/// Settings section 3: three independent locks - opening the app, switching
|
||||
/// accounts, and revealing hidden files - each behind the device's own PIN/
|
||||
/// biometric credential (see `AppLockService` - this app never stores or
|
||||
/// handles a PIN itself).
|
||||
/// handles a PIN itself). Turning one on or off asks for that credential, and
|
||||
/// none of them requires or implies another.
|
||||
class SettingsSecuritySection extends StatelessWidget {
|
||||
const SettingsSecuritySection({super.key});
|
||||
|
||||
Future<void> _handleLoginLockChanged(
|
||||
BuildContext context,
|
||||
SessionController session,
|
||||
bool value,
|
||||
) async {
|
||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
||||
Future<void> _handleLockChanged(
|
||||
BuildContext context, {
|
||||
required Future<bool> Function() change,
|
||||
required bool turningOn,
|
||||
required String name,
|
||||
}) async {
|
||||
final success = await change();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
value
|
||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off login lock',
|
||||
turningOn
|
||||
? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off $name',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@@ -64,10 +66,17 @@ class SettingsSecuritySection extends StatelessWidget {
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.lock,
|
||||
label: Text(biometricLabel(platform)),
|
||||
subtitle: const Text("Require this device's PIN or biometric to open Noo"),
|
||||
subtitle: const Text(
|
||||
"Require this device's PIN or biometric to open Noo",
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: session.loginLockEnabled,
|
||||
onChanged: (value) => _handleLoginLockChanged(context, session, value),
|
||||
onChanged: (value) => _handleLockChanged(
|
||||
context,
|
||||
change: value ? session.setupLoginLock : session.disableLoginLock,
|
||||
turningOn: value,
|
||||
name: 'login lock',
|
||||
),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
@@ -76,7 +85,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockAccountSwitching,
|
||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
||||
onChanged: (value) => _handleLockChanged(
|
||||
context,
|
||||
change: () => session.setLockAccountSwitching(value),
|
||||
turningOn: value,
|
||||
name: 'account switching lock',
|
||||
),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
@@ -85,7 +99,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockHiddenFiles,
|
||||
onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null,
|
||||
onChanged: (value) => _handleLockChanged(
|
||||
context,
|
||||
change: () => session.setLockHiddenFiles(value),
|
||||
turningOn: value,
|
||||
name: 'hidden files lock',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,8 +10,13 @@ import '../theme/design_tokens.dart';
|
||||
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double height;
|
||||
final Widget child;
|
||||
final bool floating;
|
||||
|
||||
const StickyHeaderDelegate({required this.height, required this.child});
|
||||
const StickyHeaderDelegate({
|
||||
required this.height,
|
||||
required this.child,
|
||||
this.floating = false,
|
||||
});
|
||||
|
||||
@override
|
||||
double get minExtent => height;
|
||||
@@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
// Colors.transparent` guards against the same M3 elevation-tint
|
||||
// behavior even if this ever gets a non-zero elevation.
|
||||
return Material(
|
||||
color: context.nooColors.bg,
|
||||
color: floating ? Colors.transparent : context.nooColors.bg,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
child: child,
|
||||
);
|
||||
@@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
||||
return oldDelegate.height != height || oldDelegate.child != child;
|
||||
return oldDelegate.height != height ||
|
||||
oldDelegate.child != child ||
|
||||
oldDelegate.floating != floating;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||
/// Scaffold already shrinks the body above them - this is then pure
|
||||
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
||||
/// bars draw over an extended body instead, so nothing reserves space for
|
||||
/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an
|
||||
/// extended body instead, so nothing reserves space for
|
||||
/// them automatically: the clearance has to cover the bar's own footprint
|
||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||
double bottomBarClearance(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
||||
final barStyle = settings.bottomBarStyle;
|
||||
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
|
||||
return 100;
|
||||
}
|
||||
final barHeight = NooBottomBar.rowHeight(
|
||||
NooLayout.navStyle(context),
|
||||
NooBottomBarStyle.floating,
|
||||
barStyle,
|
||||
);
|
||||
final margin = barStyle == NooBottomBarStyle.floating
|
||||
? NooBottomBar.floatingBottomMargin
|
||||
: 0;
|
||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
||||
return barHeight + margin + safeBottom + 24;
|
||||
}
|
||||
|
||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||
|
||||
@@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart';
|
||||
import '../frosted_glass_container.dart';
|
||||
import 'viewer_icon_button.dart';
|
||||
|
||||
/// The bottom panel overlaid on the media viewer: an optional transport row
|
||||
/// ([transportControls], video only) above the action row
|
||||
/// (share/favorite/open/download/delete/details) - one continuous flush,
|
||||
/// full-width `FrostedGlassContainer` panel, not two separate floating
|
||||
/// pills. Kept on that blurred chrome as a deliberate exception to the
|
||||
/// design system's flat product UI (see `file_viewer_screen.dart`'s
|
||||
/// build() comment for why), unlike the now-flat bottom nav bar
|
||||
/// (`bottom_nav_bar.dart`) this screen sits above.
|
||||
/// A full-width media panel containing transport, actions and details.
|
||||
/// Its solid/frosted surface follows the shared appearance preference.
|
||||
class MediaActionBar extends StatelessWidget {
|
||||
final bool isFavorite;
|
||||
final bool isBusy;
|
||||
|
||||
@@ -2,11 +2,9 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
|
||||
/// Space the viewer's floating top bar and bottom action bar cover, so the
|
||||
/// text sits clear of them (and the status bar) instead of scrolling
|
||||
@@ -14,16 +12,97 @@ import '../noo/core/noo_button.dart';
|
||||
const double _kTopBarClearance = 72;
|
||||
const double _kActionBarClearance = 112;
|
||||
|
||||
/// What the viewer's top bar needs from [MediaTextPreview]: whether to show
|
||||
/// the Edit/Preview toggle and the Save button, their current state, and a way
|
||||
/// to trigger them. The preview owns the text, the editing mode and the save;
|
||||
/// this just mirrors that out so `FileViewerScreen` can draw the buttons in
|
||||
/// its own top bar (they used to float inside the preview).
|
||||
class TextPreviewController extends ChangeNotifier {
|
||||
bool _canToggleEditing = false;
|
||||
bool _editing = false;
|
||||
bool _dirty = false;
|
||||
bool _saving = false;
|
||||
bool _disposed = false;
|
||||
VoidCallback? _onToggleEditing;
|
||||
Future<void> Function()? _onSave;
|
||||
|
||||
/// Markdown, writable (not the read-only Offline copy) and loaded - the
|
||||
/// Edit/Preview toggle only exists then.
|
||||
bool get canToggleEditing => _canToggleEditing;
|
||||
|
||||
/// Markdown is currently showing its raw text (the toggle then offers
|
||||
/// "Preview"); false while rendered (it offers "Edit").
|
||||
bool get editing => _editing;
|
||||
|
||||
/// The text differs from what's saved on the server.
|
||||
bool get dirty => _dirty;
|
||||
bool get saving => _saving;
|
||||
|
||||
/// The Save button is shown once the content has changed, and stays (as a
|
||||
/// spinner) while the save is in flight.
|
||||
bool get showSave => _dirty || _saving;
|
||||
|
||||
void toggleEditing() => _onToggleEditing?.call();
|
||||
Future<void> save() async => _onSave?.call();
|
||||
|
||||
void _bind(VoidCallback toggleEditing, Future<void> Function() save) {
|
||||
_onToggleEditing = toggleEditing;
|
||||
_onSave = save;
|
||||
}
|
||||
|
||||
/// The preview went away: nothing to toggle or save any more. Quiet (no
|
||||
/// notification) - it happens while the tree is being torn down.
|
||||
void _unbind() {
|
||||
_onToggleEditing = null;
|
||||
_onSave = null;
|
||||
_canToggleEditing = false;
|
||||
_editing = false;
|
||||
_dirty = false;
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
void _update({
|
||||
required bool canToggleEditing,
|
||||
required bool editing,
|
||||
required bool dirty,
|
||||
required bool saving,
|
||||
}) {
|
||||
if (_canToggleEditing == canToggleEditing &&
|
||||
_editing == editing &&
|
||||
_dirty == dirty &&
|
||||
_saving == saving) {
|
||||
return;
|
||||
}
|
||||
_canToggleEditing = canToggleEditing;
|
||||
_editing = editing;
|
||||
_dirty = dirty;
|
||||
_saving = saving;
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
||||
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
||||
/// editable when online; a Save button appears once it has changed. Markdown
|
||||
/// files open rendered, with an Edit/Preview toggle. Opened
|
||||
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
|
||||
/// local copy has no server to write back to.
|
||||
/// editable when online - after tapping Edit; every file opens read-only
|
||||
/// (Markdown rendered) with an Edit/Preview toggle - and Save appears once it
|
||||
/// has changed. Both controls live in the viewer's
|
||||
/// top bar, driven through [controller]. Opened from the Offline tab
|
||||
/// ([localPathResolver] set) it is read-only, since the local copy has no
|
||||
/// server to write back to.
|
||||
class MediaTextPreview extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
final SessionController session;
|
||||
|
||||
/// Receives this preview's editing/save state and is how the top bar's
|
||||
/// buttons reach it. Optional so the preview also works on its own.
|
||||
final TextPreviewController? controller;
|
||||
|
||||
/// When set, read bytes from the local path it resolves to instead of
|
||||
/// fetching from the server - see
|
||||
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
||||
@@ -33,6 +112,7 @@ class MediaTextPreview extends StatefulWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.session,
|
||||
this.controller,
|
||||
this.localPathResolver,
|
||||
});
|
||||
|
||||
@@ -46,12 +126,15 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
String? _error;
|
||||
bool _isSaving = false;
|
||||
bool _editing = false;
|
||||
final _focus = FocusNode();
|
||||
|
||||
bool get _isMarkdown {
|
||||
final name = widget.item.name.toLowerCase();
|
||||
return name.endsWith('.md') || name.endsWith('.markdown');
|
||||
}
|
||||
|
||||
/// Markdown shows rendered until the user taps Edit. Plain text has no
|
||||
/// rendered form, so it just shows its (read-only) text until then.
|
||||
bool get _showRendered => _isMarkdown && !_editing;
|
||||
|
||||
bool get _readOnly => widget.localPathResolver != null;
|
||||
@@ -60,16 +143,45 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() => setState(() {}));
|
||||
widget.controller?._bind(_toggleEditing, _save);
|
||||
_controller.addListener(() {
|
||||
setState(() {});
|
||||
_syncToolbar();
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller?._unbind();
|
||||
_focus.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Mirrors the editing/save state out to the viewer's top bar. Only called
|
||||
/// from event handlers and async completions - never from build or
|
||||
/// initState, where notifying the bar's listeners would be illegal.
|
||||
void _syncToolbar() {
|
||||
widget.controller?._update(
|
||||
canToggleEditing: _saved != null && !_readOnly,
|
||||
editing: _editing,
|
||||
dirty: _dirty,
|
||||
saving: _isSaving,
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleEditing() {
|
||||
setState(() => _editing = !_editing);
|
||||
_syncToolbar();
|
||||
// Tapping Edit should put the cursor in the text, not just unlock it.
|
||||
if (_editing) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _focus.requestFocus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final List<int> bytes;
|
||||
@@ -89,6 +201,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
final text = utf8.decode(bytes, allowMalformed: true);
|
||||
_controller.text = text;
|
||||
setState(() => _saved = text);
|
||||
_syncToolbar();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
@@ -99,6 +212,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final text = _controller.text;
|
||||
setState(() => _isSaving = true);
|
||||
_syncToolbar();
|
||||
var ok = false;
|
||||
try {
|
||||
ok = await widget.session.service!.putBytes(
|
||||
@@ -111,6 +225,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
_isSaving = false;
|
||||
if (ok) _saved = text;
|
||||
});
|
||||
_syncToolbar();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
||||
@@ -158,7 +273,10 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
padding: padding,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
readOnly: _readOnly,
|
||||
focusNode: _focus,
|
||||
// Read-only until Edit is tapped (always, for the
|
||||
// Offline copy).
|
||||
readOnly: _readOnly || !_editing,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: style,
|
||||
@@ -171,30 +289,6 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isMarkdown && !_readOnly)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: safe.top + _kTopBarClearance - 8,
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
|
||||
onTap: () => setState(() => _editing = !_editing),
|
||||
child: Text(_editing ? 'Preview' : 'Edit'),
|
||||
),
|
||||
),
|
||||
if (_dirty)
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: safe.bottom + _kActionBarClearance,
|
||||
child: NooButton(
|
||||
size: NooButtonSize.card,
|
||||
icon: LucideIcons.save,
|
||||
disabled: _isSaving,
|
||||
onTap: _isSaving ? null : _save,
|
||||
child: Text(_isSaving ? 'Saving…' : 'Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
@@ -198,12 +199,14 @@ class VideoTransportControls extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: WavySeekBar(
|
||||
controller: controller,
|
||||
playedColor: fg,
|
||||
trackColor: fg.withValues(alpha: 0.3),
|
||||
style: progressBarStyle,
|
||||
),
|
||||
child: Theme.of(context).platform == TargetPlatform.iOS
|
||||
? IosSeekBar(controller: controller, color: fg)
|
||||
: WavySeekBar(
|
||||
controller: controller,
|
||||
playedColor: fg,
|
||||
trackColor: fg.withValues(alpha: 0.3),
|
||||
style: progressBarStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -213,6 +216,60 @@ class VideoTransportControls extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class IosSeekBar extends StatefulWidget {
|
||||
final VideoPlayerController controller;
|
||||
final Color color;
|
||||
const IosSeekBar({super.key, required this.controller, required this.color});
|
||||
|
||||
@override
|
||||
State<IosSeekBar> createState() => _IosSeekBarState();
|
||||
}
|
||||
|
||||
class _IosSeekBarState extends State<IosSeekBar> {
|
||||
double? _dragRatio;
|
||||
bool _resumeAfterScrubbing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final value = widget.controller.value;
|
||||
final durationMs = value.duration.inMilliseconds;
|
||||
final ratio =
|
||||
(_dragRatio ??
|
||||
(durationMs > 0
|
||||
? value.position.inMilliseconds / durationMs
|
||||
: 0.0))
|
||||
.clamp(0.0, 1.0);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoSlider(
|
||||
value: ratio,
|
||||
activeColor: widget.color,
|
||||
thumbColor: CupertinoColors.white,
|
||||
onChangeStart: durationMs <= 0
|
||||
? null
|
||||
: (_) {
|
||||
_resumeAfterScrubbing = value.isPlaying;
|
||||
if (_resumeAfterScrubbing) widget.controller.pause();
|
||||
},
|
||||
onChanged: durationMs <= 0
|
||||
? null
|
||||
: (ratio) {
|
||||
setState(() => _dragRatio = ratio);
|
||||
widget.controller.seekTo(value.duration * ratio);
|
||||
},
|
||||
onChangeEnd: durationMs <= 0
|
||||
? null
|
||||
: (ratio) async {
|
||||
await widget.controller.seekTo(value.duration * ratio);
|
||||
if (!mounted) return;
|
||||
setState(() => _dragRatio = null);
|
||||
if (_resumeAfterScrubbing) widget.controller.play();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Material You "expressive" wavy seek bar: the played portion of the
|
||||
/// track animates as a travelling sine wave while playing and settles flat
|
||||
/// when paused, matching the Android 16+ media player style. Drag or tap
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/models/nextcloud_item.dart';
|
||||
import 'package:noo/providers/connectivity_controller.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
|
||||
NextcloudItem _item(String path, {String? mountType}) => NextcloudItem(
|
||||
id: path,
|
||||
name: path.split('/').last,
|
||||
path: path,
|
||||
type: NextcloudItemType.file,
|
||||
size: 1,
|
||||
lastModified: DateTime(2026),
|
||||
mountType: mountType,
|
||||
);
|
||||
|
||||
/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and
|
||||
/// how a pref saved before the three-way hidden filter is read back.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||
);
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
final items = [
|
||||
_item('/a.txt'),
|
||||
_item('/.secret'),
|
||||
_item('/docs/.git/config'),
|
||||
_item('/nas', mountType: 'external'),
|
||||
_item('/.nas-hidden', mountType: 'external'),
|
||||
];
|
||||
List<String> paths(List<NextcloudItem> l) => l.map((i) => i.path).toList();
|
||||
|
||||
FilesController build() =>
|
||||
FilesController(SessionController(ConnectivityController()));
|
||||
|
||||
test('hidden filter: hide / only / include', () {
|
||||
final files = build()..setStorageScope(StorageScope.all);
|
||||
List<String> run(HiddenFilesFilter h) => paths(
|
||||
files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h),
|
||||
);
|
||||
expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']);
|
||||
expect(run(HiddenFilesFilter.only), [
|
||||
'/.secret',
|
||||
'/docs/.git/config',
|
||||
'/.nas-hidden',
|
||||
]);
|
||||
expect(run(HiddenFilesFilter.include), paths(items));
|
||||
});
|
||||
|
||||
test('storage scope: cloud / external / all', () {
|
||||
final files = build();
|
||||
List<String> run(StorageScope s) {
|
||||
files.setStorageScope(s);
|
||||
return paths(
|
||||
files.applyCommonFilters(
|
||||
items,
|
||||
showFavoritesOnly: false,
|
||||
hidden: HiddenFilesFilter.include,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expect(run(StorageScope.cloud), [
|
||||
'/a.txt',
|
||||
'/.secret',
|
||||
'/docs/.git/config',
|
||||
]);
|
||||
expect(run(StorageScope.external), ['/nas', '/.nas-hidden']);
|
||||
expect(run(StorageScope.all), paths(items));
|
||||
});
|
||||
|
||||
test('hidden + external combine', () {
|
||||
final files = build()..setStorageScope(StorageScope.external);
|
||||
expect(
|
||||
paths(
|
||||
files.applyCommonFilters(
|
||||
items,
|
||||
showFavoritesOnly: false,
|
||||
hidden: HiddenFilesFilter.only,
|
||||
),
|
||||
),
|
||||
['/.nas-hidden'],
|
||||
);
|
||||
});
|
||||
|
||||
group('loadHiddenFilter', () {
|
||||
Future<HiddenFilesFilter> load(Map<String, Object> values) async {
|
||||
SharedPreferences.setMockInitialValues(values);
|
||||
return FilesController.loadHiddenFilter(
|
||||
await SharedPreferences.getInstance(),
|
||||
'new',
|
||||
'old',
|
||||
);
|
||||
}
|
||||
|
||||
test('defaults to hide', () async {
|
||||
expect(await load({}), HiddenFilesFilter.hide);
|
||||
});
|
||||
|
||||
test('the old show-hidden bool maps to hide / include', () async {
|
||||
expect(await load({'old': true}), HiddenFilesFilter.include);
|
||||
expect(await load({'old': false}), HiddenFilesFilter.hide);
|
||||
});
|
||||
|
||||
test('the new key wins and an unknown value falls back to hide', () async {
|
||||
expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only);
|
||||
expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/providers/connectivity_controller.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
import 'package:noo/services/app_lock_service.dart';
|
||||
|
||||
/// The three Settings -> Security locks (open the app, switch accounts, reveal
|
||||
/// hidden files) each work on their own - none needs or implies another. The
|
||||
/// device prompt is replaced with a recorder so the gates can be exercised.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||
);
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||
);
|
||||
});
|
||||
|
||||
late List<String> prompts;
|
||||
var authSucceeds = true;
|
||||
var deviceSupported = true;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prompts = [];
|
||||
authSucceeds = true;
|
||||
deviceSupported = true;
|
||||
AppLockService.debugAuthenticate = (reason) async {
|
||||
prompts.add(reason);
|
||||
return authSucceeds;
|
||||
};
|
||||
AppLockService.debugIsDeviceSupported = () async => deviceSupported;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
AppLockService.debugAuthenticate = null;
|
||||
AppLockService.debugIsDeviceSupported = null;
|
||||
});
|
||||
|
||||
SessionController build() => SessionController(ConnectivityController());
|
||||
|
||||
test(
|
||||
'hidden files and account switching can be locked without login lock',
|
||||
() async {
|
||||
final session = build();
|
||||
|
||||
expect(await session.setLockHiddenFiles(true), isTrue);
|
||||
expect(await session.setLockAccountSwitching(true), isTrue);
|
||||
|
||||
expect(session.lockHiddenFiles, isTrue);
|
||||
expect(session.lockAccountSwitching, isTrue);
|
||||
expect(session.loginLockEnabled, isFalse);
|
||||
expect(
|
||||
session.needsUnlock,
|
||||
isFalse,
|
||||
reason: 'opening the app stays open',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a gate prompts on its own, with login lock off', () async {
|
||||
final session = build();
|
||||
|
||||
expect(await session.passGate(false, 'unused'), isTrue);
|
||||
expect(prompts, isEmpty, reason: 'a gate that is off never prompts');
|
||||
|
||||
expect(await session.passGate(true, 'Unlock to show hidden files'), isTrue);
|
||||
expect(prompts, ['Unlock to show hidden files']);
|
||||
|
||||
authSucceeds = false;
|
||||
expect(await session.passGate(true, 'again'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'turning a lock on needs a capable device and a successful auth',
|
||||
() async {
|
||||
final session = build();
|
||||
|
||||
deviceSupported = false;
|
||||
expect(await session.setLockHiddenFiles(true), isFalse);
|
||||
expect(session.lockHiddenFiles, isFalse);
|
||||
|
||||
deviceSupported = true;
|
||||
authSucceeds = false;
|
||||
expect(await session.setLockHiddenFiles(true), isFalse);
|
||||
expect(session.lockHiddenFiles, isFalse);
|
||||
|
||||
authSucceeds = true;
|
||||
expect(await session.setLockHiddenFiles(true), isTrue);
|
||||
expect(session.lockHiddenFiles, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'turning a lock off needs auth, so it cannot just be flipped away',
|
||||
() async {
|
||||
final session = build();
|
||||
await session.setLockAccountSwitching(true);
|
||||
prompts.clear();
|
||||
|
||||
authSucceeds = false;
|
||||
expect(await session.setLockAccountSwitching(false), isFalse);
|
||||
expect(session.lockAccountSwitching, isTrue);
|
||||
|
||||
authSucceeds = true;
|
||||
expect(await session.setLockAccountSwitching(false), isTrue);
|
||||
expect(session.lockAccountSwitching, isFalse);
|
||||
expect(prompts, hasLength(2));
|
||||
},
|
||||
);
|
||||
|
||||
test('setting a lock to its current value does not prompt', () async {
|
||||
final session = build();
|
||||
expect(await session.setLockHiddenFiles(false), isTrue);
|
||||
expect(prompts, isEmpty);
|
||||
});
|
||||
|
||||
test('turning login lock off leaves the other two locks alone', () async {
|
||||
final session = build();
|
||||
await session.setupLoginLock();
|
||||
await session.setLockAccountSwitching(true);
|
||||
await session.setLockHiddenFiles(true);
|
||||
expect(session.loginLockEnabled, isTrue);
|
||||
|
||||
expect(await session.disableLoginLock(), isTrue);
|
||||
|
||||
expect(session.loginLockEnabled, isFalse);
|
||||
expect(session.lockAccountSwitching, isTrue);
|
||||
expect(session.lockHiddenFiles, isTrue);
|
||||
});
|
||||
|
||||
test('each lock is saved on its own and restored', () async {
|
||||
final first = build();
|
||||
await first.setLockHiddenFiles(true);
|
||||
// Let the fire-and-forget pref write land.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final second = build();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(second.lockHiddenFiles, isTrue);
|
||||
expect(second.lockAccountSwitching, isFalse);
|
||||
expect(second.loginLockEnabled, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/providers/settings_controller.dart';
|
||||
|
||||
/// The frosted-glass bottom bar's blur/opacity settings and their presets.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
Future<SettingsController> build() async {
|
||||
final settings = SettingsController();
|
||||
// `_load` is async and not exposed; one event-loop turn lets it finish.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return settings;
|
||||
}
|
||||
|
||||
test('starts on the Default preset', () async {
|
||||
final settings = await build();
|
||||
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||
expect(settings.bottomBarFrostedBlur, 20);
|
||||
expect(settings.bottomBarFrostedOpacity, 0.72);
|
||||
});
|
||||
|
||||
test('presets set both values and are recognised again', () async {
|
||||
final settings = await build();
|
||||
for (final preset in FrostedGlassPreset.values) {
|
||||
settings.setFrostedPreset(preset);
|
||||
expect(settings.bottomBarFrostedBlur, preset.blur);
|
||||
expect(settings.bottomBarFrostedOpacity, preset.opacity);
|
||||
expect(settings.frostedPreset, preset);
|
||||
}
|
||||
});
|
||||
|
||||
test('presets run from lighter to stronger frosting', () {
|
||||
expect(
|
||||
FrostedGlassPreset.less.blur,
|
||||
lessThan(FrostedGlassPreset.standard.blur),
|
||||
);
|
||||
expect(
|
||||
FrostedGlassPreset.standard.blur,
|
||||
lessThan(FrostedGlassPreset.more.blur),
|
||||
);
|
||||
expect(
|
||||
FrostedGlassPreset.less.opacity,
|
||||
greaterThan(FrostedGlassPreset.standard.opacity),
|
||||
);
|
||||
expect(
|
||||
FrostedGlassPreset.standard.opacity,
|
||||
greaterThan(FrostedGlassPreset.more.opacity),
|
||||
);
|
||||
});
|
||||
|
||||
test('moving a slider off every preset leaves none selected', () async {
|
||||
final settings = await build();
|
||||
settings.setFrostedBlur(27);
|
||||
expect(settings.frostedPreset, isNull);
|
||||
settings.setFrostedBlur(FrostedGlassPreset.standard.blur);
|
||||
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||
});
|
||||
|
||||
test('values are clamped to their ranges', () async {
|
||||
final settings = await build();
|
||||
settings
|
||||
..setFrostedBlur(500)
|
||||
..setFrostedOpacity(0);
|
||||
expect(settings.bottomBarFrostedBlur, maxFrostedBlur);
|
||||
expect(settings.bottomBarFrostedOpacity, minFrostedOpacity);
|
||||
settings
|
||||
..setFrostedBlur(-3)
|
||||
..setFrostedOpacity(9);
|
||||
expect(settings.bottomBarFrostedBlur, minFrostedBlur);
|
||||
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||
});
|
||||
|
||||
test('saved values are restored (and clamped) on the next launch', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'ui_bottom_bar_frosted_blur': 32.0,
|
||||
'ui_bottom_bar_frosted_opacity': 7.0,
|
||||
});
|
||||
final settings = await build();
|
||||
expect(settings.bottomBarFrostedBlur, 32);
|
||||
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/providers/settings_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'defaults to top and safely handles an unknown saved position',
|
||||
() async {
|
||||
for (final saved in [null, 'unknown']) {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'ui_selection_bar_position': ?saved,
|
||||
});
|
||||
final settings = SettingsController();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(settings.selectionBarPosition, SelectionBarPosition.top);
|
||||
settings.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'bottom position persists and restores across controller creation',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final settings = SettingsController();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
settings.setSelectionBarPosition(SelectionBarPosition.bottom);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final restored = SettingsController();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(restored.selectionBarPosition, SelectionBarPosition.bottom);
|
||||
settings.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
test(
|
||||
'avatar position defaults to top and restores a saved bottom choice',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final settings = SettingsController();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(settings.avatarPosition, AvatarPosition.top);
|
||||
settings.setAvatarPosition(AvatarPosition.bottom);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final restored = SettingsController();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(restored.avatarPosition, AvatarPosition.bottom);
|
||||
settings.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:noo/services/native_channel.dart';
|
||||
import 'package:noo/services/share_intent_service.dart';
|
||||
|
||||
/// iOS has no handler for the `dev.ayushya.noo/*` channels yet, so these use
|
||||
/// channels with nothing registered - the same `MissingPluginException` the
|
||||
/// simulator showed at startup.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('test/no_native_side');
|
||||
const events = EventChannel('test/no_native_side/events');
|
||||
|
||||
test(
|
||||
'invokeIfAvailable returns null when nothing implements the channel',
|
||||
() {
|
||||
expect(
|
||||
invokeIfAvailable<String>(channel, 'anything'),
|
||||
completion(isNull),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('invokeIfAvailable still returns a real result', () async {
|
||||
const live = MethodChannel('test/live');
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(live, (call) async => 'pong');
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(live, null),
|
||||
);
|
||||
expect(await invokeIfAvailable<String>(live, 'ping'), 'pong');
|
||||
});
|
||||
|
||||
test('invokeOrExplain names the feature the user asked for', () {
|
||||
expect(
|
||||
invokeOrExplain(channel, 'startUpload', 'Uploading'),
|
||||
throwsA(
|
||||
isA<NativeServiceUnavailable>().having(
|
||||
(e) => e.toString(),
|
||||
'message',
|
||||
"Uploading isn't available on this platform yet.",
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('quietEvents swallows the missing-implementation error', () async {
|
||||
final errors = <Object>[];
|
||||
final values = <int>[];
|
||||
final done = Completer<void>();
|
||||
quietEvents<int>(
|
||||
events,
|
||||
(e) => e as int,
|
||||
).listen(values.add, onError: errors.add, onDone: done.complete);
|
||||
// Give the (failing) native `listen` call time to come back.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(errors, isEmpty);
|
||||
expect(values, isEmpty);
|
||||
});
|
||||
|
||||
test('ShareIntentService has nothing to report without a native side', () {
|
||||
expect(ShareIntentService.getInitialShare(), completion(isEmpty));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:noo/services/share_account_service.dart';
|
||||
|
||||
/// What the iOS Share Extension is given - the contract with
|
||||
/// `ios/Shared/SharedAccount.swift`'s `SharedAccounts`.
|
||||
void main() {
|
||||
const alice = ShareAccountEntry(
|
||||
id: 'cloud_example_com__alice',
|
||||
serverUrl: 'https://cloud.example.com',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
hiddenFilter: 'include',
|
||||
storageScope: 'all',
|
||||
);
|
||||
const bob = ShareAccountEntry(
|
||||
id: 'nc_home_lan__bob',
|
||||
serverUrl: 'https://nc.home.lan:8443/nextcloud',
|
||||
username: 'bob',
|
||||
password: 'pw',
|
||||
hiddenFilter: 'hide',
|
||||
storageScope: 'cloud',
|
||||
);
|
||||
|
||||
Map<String, dynamic> build({
|
||||
List<ShareAccountEntry> accounts = const [alice, bob],
|
||||
String? activeId = 'nc_home_lan__bob',
|
||||
bool login = true,
|
||||
bool switching = true,
|
||||
bool hidden = false,
|
||||
}) => jsonDecode(
|
||||
ShareAccountService.buildPayload(
|
||||
accounts: accounts,
|
||||
activeId: activeId,
|
||||
loginLockEnabled: login,
|
||||
lockAccountSwitching: switching,
|
||||
lockHiddenFiles: hidden,
|
||||
),
|
||||
);
|
||||
|
||||
test('basic auth header matches what the app sends', () {
|
||||
expect(
|
||||
ShareAccountService.basicAuth('alice', 's3cret'),
|
||||
'Basic ${base64Encode(utf8.encode('alice:s3cret'))}',
|
||||
);
|
||||
});
|
||||
|
||||
test('every account is listed with its own header, name and filter', () {
|
||||
final accounts = (build()['accounts'] as List).cast<Map<String, dynamic>>();
|
||||
expect(accounts.map((a) => a['id']), [alice.id, bob.id]);
|
||||
expect(
|
||||
accounts[0]['authHeader'],
|
||||
ShareAccountService.basicAuth('alice', 's3cret'),
|
||||
);
|
||||
expect(accounts[0]['displayName'], 'alice@cloud.example.com');
|
||||
expect(accounts[0]['hiddenFilter'], 'include');
|
||||
expect(accounts[0]['storageScope'], 'all');
|
||||
// The display name uses the host only - not the port or sub-path.
|
||||
expect(accounts[1]['displayName'], 'bob@nc.home.lan');
|
||||
expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud');
|
||||
expect(accounts[1]['hiddenFilter'], 'hide');
|
||||
expect(accounts[1]['storageScope'], 'cloud');
|
||||
});
|
||||
|
||||
test('the active account and the lock settings are passed through', () {
|
||||
final payload = build(login: true, switching: false, hidden: true);
|
||||
expect(payload['activeId'], 'nc_home_lan__bob');
|
||||
expect(payload['loginLockEnabled'], true);
|
||||
expect(payload['lockAccountSwitching'], false);
|
||||
expect(payload['lockHiddenFiles'], true);
|
||||
});
|
||||
|
||||
test('no active account is a null activeId, not a missing key', () {
|
||||
expect(build(activeId: null).containsKey('activeId'), true);
|
||||
expect(build(activeId: null)['activeId'], isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:noo/models/nextcloud_item.dart';
|
||||
import 'package:noo/providers/item_operations.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
import 'package:noo/services/nextcloud_service.dart';
|
||||
import 'package:noo/views/file_viewer_screen.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_button.dart';
|
||||
import 'package:noo/widgets/viewer/media_action_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../widgets/noo/noo_test_utils.dart';
|
||||
|
||||
class _Service extends NextcloudService {
|
||||
_Service() : super(serverUrl: '', username: '', password: '');
|
||||
|
||||
final loaded = Completer<List<int>>();
|
||||
Completer<bool>? saving;
|
||||
String? written;
|
||||
|
||||
@override
|
||||
Future<List<int>> fetchBytes(String itemPath) => loaded.future;
|
||||
|
||||
@override
|
||||
Future<bool> putBytes(String itemPath, List<int> bytes) {
|
||||
written = utf8.decode(bytes);
|
||||
saving = Completer<bool>();
|
||||
return saving!.future;
|
||||
}
|
||||
}
|
||||
|
||||
// These stand-ins avoid starting session restoration and platform channels.
|
||||
class _Session extends ChangeNotifier implements SessionController {
|
||||
_Session(this.service);
|
||||
|
||||
@override
|
||||
final NextcloudService service;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Operations implements ItemOperations {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Finder _button(String tooltip) => find.byTooltip(tooltip);
|
||||
|
||||
/// The [NooButton] behind the top-bar action tagged [tooltip].
|
||||
Finder _noo(String tooltip) =>
|
||||
find.descendant(of: _button(tooltip), matching: find.byType(NooButton));
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
Future<_Service> mount(
|
||||
WidgetTester tester,
|
||||
Size size, {
|
||||
String name = 'notes.md',
|
||||
bool offline = false,
|
||||
}) async {
|
||||
final service = _Service();
|
||||
final session = _Session(service);
|
||||
addTearDown(session.dispose);
|
||||
await tester.binding.setSurfaceSize(size);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<SessionController>.value(value: session),
|
||||
Provider<ItemOperations>.value(value: _Operations()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: nooTheme(Brightness.light),
|
||||
home: FileViewerScreen(
|
||||
item: NextcloudItem(
|
||||
id: '1',
|
||||
name: name,
|
||||
path: '/$name',
|
||||
type: NextcloudItemType.file,
|
||||
size: 12,
|
||||
lastModified: DateTime(2026),
|
||||
),
|
||||
localPathResolver: offline ? (_) async => null : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
for (final width in [390.0, 1200.0]) {
|
||||
testWidgets('text actions use the top bar at width $width', (tester) async {
|
||||
final service = await mount(tester, Size(width, 800));
|
||||
expect(_button('Edit'), findsNothing);
|
||||
expect(_button('Save'), findsNothing);
|
||||
service.loaded.complete(utf8.encode('# Original'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
void inHeader(String label) {
|
||||
final row = find
|
||||
.ancestor(of: _button('Back'), matching: find.byType(Row))
|
||||
.first;
|
||||
expect(
|
||||
find.descendant(of: row, matching: _button(label)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(MediaActionBar),
|
||||
matching: _button(label),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
}
|
||||
|
||||
inHeader('Edit');
|
||||
// A lone action is the primary one, and a compact round icon button.
|
||||
expect(
|
||||
tester.widget<NooButton>(_noo('Edit')).variant,
|
||||
NooButtonVariant.primary,
|
||||
);
|
||||
expect(tester.getSize(_noo('Edit')), const Size(36, 36));
|
||||
expect(find.byType(Markdown), findsOneWidget);
|
||||
await tester.tap(_button('Edit'));
|
||||
await tester.pumpAndSettle();
|
||||
inHeader('Preview');
|
||||
await tester.enterText(find.byType(TextField), '# Changed');
|
||||
await tester.pump();
|
||||
inHeader('Save');
|
||||
// With two, Save is primary and the toggle steps back to secondary.
|
||||
expect(
|
||||
tester.widget<NooButton>(_noo('Save')).variant,
|
||||
NooButtonVariant.primary,
|
||||
);
|
||||
expect(
|
||||
tester.widget<NooButton>(_noo('Preview')).variant,
|
||||
NooButtonVariant.secondary,
|
||||
);
|
||||
await tester.tap(_button('Preview'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Markdown>(find.byType(Markdown)).data, '# Changed');
|
||||
inHeader('Save');
|
||||
await tester.tap(_button('Save'));
|
||||
await tester.pump();
|
||||
expect(service.written, '# Changed');
|
||||
// While the save is in flight the Save button gives way to a spinner,
|
||||
// so it can't be tapped twice.
|
||||
expect(_button('Save'), findsNothing);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Saving',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
service.saving!.complete(false);
|
||||
await tester.pumpAndSettle();
|
||||
inHeader('Save');
|
||||
expect(find.text('Could not save file'), findsOneWidget);
|
||||
await tester.tap(_button('Save'));
|
||||
await tester.pump();
|
||||
service.saving!.complete(true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_button('Save'), findsNothing);
|
||||
expect(_button('Edit'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('plain text opens read-only and follows Edit -> Save too', (
|
||||
tester,
|
||||
) async {
|
||||
final service = await mount(
|
||||
tester,
|
||||
const Size(390, 800),
|
||||
name: 'notes.txt',
|
||||
);
|
||||
service.loaded.complete(utf8.encode('Original'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
bool readOnly() =>
|
||||
tester.widget<TextField>(find.byType(TextField)).readOnly;
|
||||
expect(readOnly(), isTrue, reason: 'not directly editable');
|
||||
expect(_button('Edit'), findsOneWidget);
|
||||
expect(_button('Save'), findsNothing);
|
||||
expect(
|
||||
tester.widget<NooButton>(_noo('Edit')).variant,
|
||||
NooButtonVariant.primary,
|
||||
);
|
||||
|
||||
await tester.tap(_button('Edit'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readOnly(), isFalse);
|
||||
expect(_button('Preview'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'Changed');
|
||||
await tester.pump();
|
||||
expect(_button('Save'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<NooButton>(_noo('Save')).variant,
|
||||
NooButtonVariant.primary,
|
||||
);
|
||||
|
||||
// Preview goes back to the read-only view, keeping the change unsaved.
|
||||
await tester.tap(_button('Preview'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readOnly(), isTrue);
|
||||
expect(find.text('Changed'), findsOneWidget);
|
||||
expect(_button('Save'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
for (final offline in [false, true]) {
|
||||
testWidgets('no text actions for offline=$offline unsupported files', (
|
||||
tester,
|
||||
) async {
|
||||
await mount(
|
||||
tester,
|
||||
const Size(390, 800),
|
||||
name: offline ? 'notes.md' : 'data.bin',
|
||||
offline: offline,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_button('Edit'), findsNothing);
|
||||
expect(_button('Preview'), findsNothing);
|
||||
expect(_button('Save'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -15,7 +16,12 @@ import 'package:noo/providers/settings_controller.dart';
|
||||
import 'package:noo/providers/sync_status_controller.dart';
|
||||
import 'package:noo/providers/trash_controller.dart';
|
||||
import 'package:noo/theme/app_theme.dart';
|
||||
import 'package:noo/theme/design_tokens.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_avatar.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_button.dart';
|
||||
import 'package:noo/widgets/app_top_bar.dart';
|
||||
import 'package:noo/widgets/avatar_menu.dart';
|
||||
import 'package:noo/widgets/bottom_nav_bar.dart';
|
||||
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
||||
|
||||
/// Covers `SettingsController.navMenuStyle`'s two options as wired through
|
||||
@@ -84,7 +90,24 @@ void main() {
|
||||
Future<void> pumpTopBar(
|
||||
WidgetTester tester, {
|
||||
required NooNavMenuStyle navMenuStyle,
|
||||
bool withHost = false,
|
||||
AvatarPosition position = AvatarPosition.top,
|
||||
bool search = false,
|
||||
bool reducedMotion = false,
|
||||
NooNavStyle style = NooNavStyle.android,
|
||||
NooBottomBarStyle barStyle = NooBottomBarStyle.floating,
|
||||
AppTab tab = AppTab.files,
|
||||
FabStyle uploadStyle = FabStyle.auto,
|
||||
bool frosted = false,
|
||||
int settleFrames = 30,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('ui_nav_menu_style', navMenuStyle.name);
|
||||
await prefs.setString('ui_avatar_position', position.name);
|
||||
await prefs.setString('ui_bottom_bar_style', barStyle.name);
|
||||
await prefs.setBool('ui_bottom_bar_frosted', frosted);
|
||||
await prefs.setDouble('ui_bottom_bar_frosted_blur', 19);
|
||||
await prefs.setDouble('ui_bottom_bar_frosted_opacity', 0.45);
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
@@ -108,15 +131,49 @@ void main() {
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
||||
home: Scaffold(
|
||||
drawer: const Drawer(child: Text('drawer open')),
|
||||
appBar: AppTopBar(
|
||||
style: NooNavStyle.android,
|
||||
tab: AppTab.files,
|
||||
navMenuStyle: navMenuStyle,
|
||||
),
|
||||
body: const SizedBox(),
|
||||
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false)
|
||||
.copyWith(
|
||||
platform: style == NooNavStyle.ios
|
||||
? TargetPlatform.iOS
|
||||
: TargetPlatform.android,
|
||||
),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
final scaffold = Scaffold(
|
||||
drawer: const Drawer(child: Text('drawer open')),
|
||||
appBar: AppTopBar(
|
||||
style: style,
|
||||
tab: tab,
|
||||
uploadButtonStyle: uploadStyle,
|
||||
navMenuStyle: navMenuStyle,
|
||||
avatarPosition: position,
|
||||
searchInBottomBar: search,
|
||||
),
|
||||
body: const AvatarNavigationBody(
|
||||
child: SizedBox.expand(key: ValueKey('page content')),
|
||||
),
|
||||
bottomNavigationBar: withHost
|
||||
? BottomNavBar(
|
||||
style: style,
|
||||
barStyle: barStyle,
|
||||
tabs: const [AppTab.files, AppTab.photos],
|
||||
selectedIndex: 0,
|
||||
onDestinationSelected: (_) {},
|
||||
avatarInBottomBar: position == AvatarPosition.bottom,
|
||||
onSearchTap: search ? () {} : null,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
final child = withHost
|
||||
? AvatarNavigationHost(child: scaffold)
|
||||
: scaffold;
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: reducedMotion),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -125,7 +182,7 @@ void main() {
|
||||
// Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`:
|
||||
// the active account's dependent controllers start periodic
|
||||
// timers/futures that would spin it forever.
|
||||
for (var i = 0; i < 30; i++) {
|
||||
for (var i = 0; i < settleFrames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
@@ -174,4 +231,443 @@ void main() {
|
||||
expect(find.text('Add account'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("other accounts' avatars sit flush right in the dropdown", (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'account_migration_v1_done': true,
|
||||
'accounts_list': jsonEncode([
|
||||
const SavedAccount(
|
||||
id: accountId,
|
||||
serverUrl: 'https://server.example.com',
|
||||
username: 'alice',
|
||||
).toJson(),
|
||||
const SavedAccount(
|
||||
id: 'other_example_org__bob',
|
||||
serverUrl: 'https://other.example.org',
|
||||
username: 'bob',
|
||||
).toJson(),
|
||||
]),
|
||||
'active_account_id': accountId,
|
||||
});
|
||||
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu);
|
||||
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
// Expand the account list from the header.
|
||||
await tester.tap(find.text('alice'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
final row = find.ancestor(
|
||||
of: find.text('bob'),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
final avatar = find.descendant(of: row, matching: find.byType(NooAvatar));
|
||||
expect(avatar, findsOneWidget);
|
||||
|
||||
// Only the row's own padding between the avatar and the card's edge -
|
||||
// no extra gutter pushing it in from the right.
|
||||
expect(
|
||||
tester.getTopRight(row).dx - tester.getTopRight(avatar).dx,
|
||||
NooSpace.md,
|
||||
);
|
||||
// The card is as wide as the Files list's rows: inset NooSpace.sm from
|
||||
// each screen edge, less its 1px border and 1px inner padding per side.
|
||||
final screenWidth = tester.getSize(find.byType(MaterialApp)).width;
|
||||
expect(tester.getSize(row).width, screenWidth - 2 * NooSpace.sm - 4);
|
||||
});
|
||||
testWidgets(
|
||||
'top avatar expands and pushes content down; closing restores it',
|
||||
(tester) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
);
|
||||
final page = find.byKey(const ValueKey('page content'));
|
||||
final before = tester.getTopLeft(page).dy;
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
final during = tester.getTopLeft(page).dy;
|
||||
expect(during, greaterThan(before));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(tester.getTopLeft(page).dy, greaterThan(during));
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
await tester.tap(find.bySemanticsLabel('Close menu').last);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(tester.getTopLeft(page).dy, before);
|
||||
expect(find.byType(AvatarMenuCard), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
for (final search in [false, true]) {
|
||||
testWidgets(
|
||||
'bottom avatar moves into card without shifting content (search=$search)',
|
||||
(tester) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
position: AvatarPosition.bottom,
|
||||
search: search,
|
||||
);
|
||||
final page = find.byKey(const ValueKey('page content'));
|
||||
final before = tester.getTopLeft(page);
|
||||
expect(find.byTooltip('Menu'), findsOneWidget);
|
||||
final origin = tester.getCenter(find.byTooltip('Menu'));
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
final moving = tester.getCenter(
|
||||
find.bySemanticsLabel('Close menu').last,
|
||||
);
|
||||
expect(moving.dy, lessThan(origin.dy));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(tester.getTopLeft(page), before);
|
||||
final card = find.byType(AvatarMenuCard);
|
||||
expect(card, findsOneWidget);
|
||||
expect(tester.getBottomRight(card).dy, lessThan(origin.dy));
|
||||
expect(tester.getSize(card).width, 376);
|
||||
if (search) expect(find.bySemanticsLabel('Search'), findsOneWidget);
|
||||
await tester.tap(find.bySemanticsLabel('Close menu').last);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byType(AvatarMenuCard), findsNothing);
|
||||
expect(tester.getCenter(find.byTooltip('Menu')), origin);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('reduced motion opens immediately and system back dismisses', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
reducedMotion: true,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(find.byType(AvatarMenuCard), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
for (final position in AvatarPosition.values) {
|
||||
for (final frosted in [false, true]) {
|
||||
testWidgets(
|
||||
'$position popup shares frost toggle and strength ($frosted)',
|
||||
(tester) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
position: position,
|
||||
frosted: frosted,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
final card = find.byType(AvatarMenuCard);
|
||||
final backdrop = find.ancestor(
|
||||
of: card,
|
||||
matching: find.byType(BackdropFilter),
|
||||
);
|
||||
expect(backdrop, frosted ? findsOneWidget : findsNothing);
|
||||
if (frosted) {
|
||||
expect(
|
||||
tester.widget<BackdropFilter>(backdrop).filter,
|
||||
ImageFilter.blur(sigmaX: 19, sigmaY: 19),
|
||||
);
|
||||
final decorations = tester.widgetList<DecoratedBox>(
|
||||
find.ancestor(of: card, matching: find.byType(DecoratedBox)),
|
||||
);
|
||||
expect(
|
||||
decorations.any(
|
||||
(box) =>
|
||||
box.decoration is BoxDecoration &&
|
||||
((box.decoration as BoxDecoration).color?.a ?? 0) > 0.44 &&
|
||||
((box.decoration as BoxDecoration).color?.a ?? 0) < 0.46,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final materials = tester.widgetList<Material>(
|
||||
find.descendant(of: card, matching: find.byType(Material)),
|
||||
);
|
||||
expect(
|
||||
materials.every(
|
||||
(material) => material.color == Colors.transparent,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('iOS upload width interpolates while its label fades', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
style: NooNavStyle.ios,
|
||||
uploadStyle: FabStyle.mini,
|
||||
);
|
||||
final button = find.byType(NooButton);
|
||||
final collapsedWidth = tester.getSize(button).width;
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
style: NooNavStyle.ios,
|
||||
uploadStyle: FabStyle.expanded,
|
||||
settleFrames: 0,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final middleWidth = tester.getSize(button).width;
|
||||
final labelOpacity = tester
|
||||
.widget<Opacity>(
|
||||
find
|
||||
.ancestor(of: find.text('Upload'), matching: find.byType(Opacity))
|
||||
.first,
|
||||
)
|
||||
.opacity;
|
||||
expect(labelOpacity, greaterThan(0));
|
||||
expect(labelOpacity, lessThan(1));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
final expandedWidth = tester.getSize(button).width;
|
||||
expect(middleWidth, greaterThan(collapsedWidth));
|
||||
expect(middleWidth, lessThan(expandedWidth));
|
||||
expect(tester.getSize(button).height, 32);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final uploadStyle in FabStyle.values) {
|
||||
for (final tab in [AppTab.files, AppTab.photos, AppTab.favorites]) {
|
||||
testWidgets('iOS title row uses $uploadStyle upload control on $tab', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
style: NooNavStyle.ios,
|
||||
tab: tab,
|
||||
uploadStyle: uploadStyle,
|
||||
);
|
||||
final title = find.descendant(
|
||||
of: find.byType(AppTopBar),
|
||||
matching: find.text(tab.label),
|
||||
);
|
||||
final titleY = tester.getCenter(title).dy;
|
||||
expect(
|
||||
tester.getCenter(find.byTooltip('Upload')).dy,
|
||||
closeTo(titleY, 1),
|
||||
);
|
||||
expect(tester.getCenter(find.byTooltip('Menu')).dy, closeTo(titleY, 1));
|
||||
final expanded =
|
||||
uploadStyle == FabStyle.expanded ||
|
||||
(uploadStyle == FabStyle.auto && tab != AppTab.favorites);
|
||||
expect(find.text('Upload'), expanded ? findsOneWidget : findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('iOS attached navigation keeps popup above its rail', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final accounts = jsonDecode(prefs.getString('accounts_list')!) as List;
|
||||
accounts.add(
|
||||
const SavedAccount(
|
||||
id: 'other_example_org__bob',
|
||||
serverUrl: 'https://other.example.org',
|
||||
username: 'bob',
|
||||
).toJson(),
|
||||
);
|
||||
await prefs.setString('accounts_list', jsonEncode(accounts));
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
position: AvatarPosition.bottom,
|
||||
search: true,
|
||||
style: NooNavStyle.ios,
|
||||
barStyle: NooBottomBarStyle.attached,
|
||||
);
|
||||
final origin = tester.getCenter(find.byTooltip('Menu'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(BottomNavBar),
|
||||
matching: find.text('Files'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Photos'), findsNothing);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(AvatarMenuCard)).dy,
|
||||
origin.dy - 25 - 1,
|
||||
);
|
||||
expect(tester.getSize(find.byType(AvatarMenuCard)).width, 400);
|
||||
expect(tester.getCenter(find.byTooltip('Menu')), origin);
|
||||
expect(find.bySemanticsLabel('Search'), findsOneWidget);
|
||||
await tester.tap(find.text('alice'));
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(
|
||||
tester.getCenter(find.text('Settings')).dy,
|
||||
lessThan(tester.getCenter(find.text('alice')).dy),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(find.text('alice')).dy,
|
||||
lessThan(tester.getCenter(find.text('bob')).dy),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(find.text('bob')).dy,
|
||||
lessThan(tester.getCenter(find.text('Add Account')).dy),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byType(AvatarMenuCard), findsNothing);
|
||||
expect(tester.getCenter(find.byTooltip('Menu')), origin);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Android floating popup orders navigation before accounts', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final accounts = jsonDecode(prefs.getString('accounts_list')!) as List;
|
||||
accounts.add(
|
||||
const SavedAccount(
|
||||
id: 'other_example_org__bob',
|
||||
serverUrl: 'https://other.example.org',
|
||||
username: 'bob',
|
||||
).toJson(),
|
||||
);
|
||||
await prefs.setString('accounts_list', jsonEncode(accounts));
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
position: AvatarPosition.bottom,
|
||||
search: true,
|
||||
style: NooNavStyle.android,
|
||||
barStyle: NooBottomBarStyle.floating,
|
||||
);
|
||||
final origin = tester.getCenter(find.byTooltip('Menu'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(BottomNavBar),
|
||||
matching: find.text('Files'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Photos'), findsNothing);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(AvatarMenuCard)).dy,
|
||||
origin.dy - 32 - 12 - 1,
|
||||
);
|
||||
expect(tester.getSize(find.byType(AvatarMenuCard)).width, 376);
|
||||
expect(find.bySemanticsLabel('Search'), findsOneWidget);
|
||||
await tester.tap(find.text('alice'));
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(
|
||||
tester.getCenter(find.text('Settings')).dy,
|
||||
lessThan(tester.getCenter(find.text('alice')).dy),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(find.text('alice')).dy,
|
||||
lessThan(tester.getCenter(find.text('bob')).dy),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(find.text('bob')).dy,
|
||||
lessThan(tester.getCenter(find.text('Add Account')).dy),
|
||||
);
|
||||
final currentAvatar = find
|
||||
.descendant(
|
||||
of: find.byType(AvatarMenuCard),
|
||||
matching: find.byType(NooAvatar),
|
||||
)
|
||||
.first;
|
||||
expect(
|
||||
tester.getCenter(currentAvatar).dy,
|
||||
lessThan(tester.getCenter(find.text('bob')).dy),
|
||||
);
|
||||
await tester.tap(currentAvatar);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byType(AvatarMenuCard), findsNothing);
|
||||
expect(tester.getCenter(find.byTooltip('Menu')), origin);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('expanding accounts also grows the top content offset', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
final before = tester
|
||||
.getTopLeft(find.byKey(const ValueKey('page content')))
|
||||
.dy;
|
||||
await tester.tap(find.text('alice'));
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(find.text('Add Account'), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('page content'))).dy,
|
||||
greaterThan(before),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
testWidgets('Search satellite closes the bottom navigation popup', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(
|
||||
tester,
|
||||
navMenuStyle: NooNavMenuStyle.avatarMenu,
|
||||
withHost: true,
|
||||
position: AvatarPosition.bottom,
|
||||
search: true,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.tap(find.bySemanticsLabel('Search'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byType(AvatarMenuCard), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/widgets/filter_mode_rows.dart';
|
||||
|
||||
import 'noo/noo_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('HiddenFilesFilterRow', () {
|
||||
testNooWidgets(
|
||||
'only the selected option shows its label, tapping another reports it',
|
||||
(tester, theme, c) async {
|
||||
var value = HiddenFilesFilter.hide;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 360,
|
||||
child: HiddenFilesFilterRow(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Hidden files'), findsOneWidget);
|
||||
expect(find.text('Hide'), findsOneWidget);
|
||||
expect(find.text('Only hidden'), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, HiddenFilesFilter.include);
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('StorageScopeRow', () {
|
||||
testNooWidgets('offers cloud only / only external / all', (
|
||||
tester,
|
||||
theme,
|
||||
c,
|
||||
) async {
|
||||
var value = StorageScope.cloud;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 360,
|
||||
child: StorageScopeRow(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('External storage'), findsOneWidget);
|
||||
expect(find.text('Cloud only'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.hardDrive).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, StorageScope.external);
|
||||
expect(find.text('Only external'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, StorageScope.all);
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/providers/settings_controller.dart';
|
||||
import 'package:noo/widgets/frosted_glass_container.dart';
|
||||
import 'noo/noo_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
testNooWidgets('viewer glass follows toggle, blur and opacity', (
|
||||
tester,
|
||||
theme,
|
||||
colors,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'ui_bottom_bar_frosted': false,
|
||||
'ui_bottom_bar_frosted_blur': 19.0,
|
||||
'ui_bottom_bar_frosted_opacity': 0.45,
|
||||
});
|
||||
final settings = SettingsController();
|
||||
await pumpNoo(
|
||||
tester,
|
||||
ChangeNotifierProvider.value(
|
||||
value: settings,
|
||||
child: const FrostedGlassContainer(
|
||||
child: SizedBox(width: 200, height: 80),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(BackdropFilter), findsNothing);
|
||||
final fill = find.descendant(
|
||||
of: find.byType(FrostedGlassContainer),
|
||||
matching: find.byType(Container),
|
||||
);
|
||||
expect(
|
||||
(tester.widget<Container>(fill).decoration as BoxDecoration).color,
|
||||
colors.surface,
|
||||
);
|
||||
settings.setBottomBarFrosted(true);
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.widget<BackdropFilter>(find.byType(BackdropFilter)).filter,
|
||||
ImageFilter.blur(sigmaX: 19, sigmaY: 19),
|
||||
);
|
||||
expect(
|
||||
(tester.widget<Container>(fill).decoration as BoxDecoration).color,
|
||||
colors.surface.withValues(alpha: 0.45),
|
||||
);
|
||||
settings.setBottomBarFrosted(false);
|
||||
await tester.pump();
|
||||
expect(find.byType(BackdropFilter), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
settings.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:noo/widgets/viewer/media_video_preview.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'noo/noo_test_utils.dart';
|
||||
|
||||
class _Controller extends VideoPlayerController {
|
||||
_Controller() : super.networkUrl(Uri.parse('https://example.com/video.mp4'));
|
||||
Duration? sought;
|
||||
@override
|
||||
Future<void> seekTo(Duration position) async {
|
||||
sought = position;
|
||||
value = value.copyWith(position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
value = value.copyWith(isPlaying: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
value = value.copyWith(isPlaying: true);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
for (final playing in [false, true]) {
|
||||
testWidgets('iOS scrubbing preserves playing=$playing', (tester) async {
|
||||
final controller = _Controller();
|
||||
controller.value = VideoPlayerValue(
|
||||
duration: const Duration(seconds: 60),
|
||||
position: const Duration(seconds: 15),
|
||||
isInitialized: true,
|
||||
isPlaying: playing,
|
||||
);
|
||||
await pumpNoo(
|
||||
tester,
|
||||
IosSeekBar(controller: controller, color: CupertinoColors.white),
|
||||
);
|
||||
final slider = tester.widget<CupertinoSlider>(
|
||||
find.byType(CupertinoSlider),
|
||||
);
|
||||
expect(slider.value, 0.25);
|
||||
slider.onChangeStart!(0.25);
|
||||
expect(controller.value.isPlaying, false);
|
||||
slider.onChanged!(0.5);
|
||||
await tester.pump();
|
||||
expect(controller.sought, const Duration(seconds: 30));
|
||||
slider.onChangeEnd!(0.5);
|
||||
await tester.pump();
|
||||
expect(controller.value.isPlaying, playing);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await controller.dispose();
|
||||
});
|
||||
}
|
||||
testWidgets('iOS scrubber disables seeking before duration is available', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = _Controller();
|
||||
await pumpNoo(
|
||||
tester,
|
||||
IosSeekBar(controller: controller, color: CupertinoColors.white),
|
||||
);
|
||||
expect(
|
||||
tester.widget<CupertinoSlider>(find.byType(CupertinoSlider)).onChanged,
|
||||
isNull,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await controller.dispose();
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:noo/widgets/noo/core/noo_chip.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_fab.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_slider.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
||||
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
|
||||
@@ -74,6 +75,59 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('NooColors.fromDynamicScheme surfaces', () {
|
||||
for (final brightness in Brightness.values) {
|
||||
test('cards are clearly lighter than the page (${brightness.name})', () {
|
||||
// The OS palette overrides `surface` (what `bg` maps to) but not the
|
||||
// seeded container roles - mimic that mismatch.
|
||||
final seeded = ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF3F6B8F),
|
||||
brightness: brightness,
|
||||
);
|
||||
final os = seeded.copyWith(
|
||||
surface: brightness == Brightness.light
|
||||
? const Color(0xFFF7F9FC)
|
||||
: const Color(0xFF101417),
|
||||
);
|
||||
final c = NooColors.fromDynamicScheme(os);
|
||||
// WCAG-style contrast ratio between the card and the page.
|
||||
final hi = c.surface.computeLuminance();
|
||||
final lo = c.bg.computeLuminance();
|
||||
expect(hi, greaterThan(lo));
|
||||
expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('NooSlider', () {
|
||||
testNooWidgets('dragging reports a value within min..max', (
|
||||
tester,
|
||||
theme,
|
||||
c,
|
||||
) async {
|
||||
var value = 10.0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 300,
|
||||
child: NooSlider(
|
||||
value: value,
|
||||
min: 0,
|
||||
max: 40,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
await tester.drag(find.byType(Slider), const Offset(150, 0));
|
||||
await tester.pump();
|
||||
expect(value, greaterThan(10));
|
||||
expect(value, lessThanOrEqualTo(40));
|
||||
});
|
||||
});
|
||||
|
||||
group('NooAvatar', () {
|
||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||
tester,
|
||||
|
||||