Compare commits
@@ -57,15 +57,16 @@ new provider instance):
|
|||||||
saved-accounts list, which one is active, `sessionGeneration` — see
|
saved-accounts list, which one is active, `sessionGeneration` — see
|
||||||
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
||||||
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
||||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
|
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
|
||||||
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||||
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
||||||
controllers — constructed after `SessionController` and unable to hold a
|
controllers — constructed after `SessionController` and unable to hold a
|
||||||
forward reference to it — can react to login/logout/account-switch
|
forward reference to it — can react to login/logout/account-switch
|
||||||
without a circular dependency.
|
without a circular dependency.
|
||||||
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
|
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
|
||||||
global UI prefs independent of login state: theme mode/seed color/dynamic
|
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`/
|
order/visibility/default (`requestedTab`/`requestTab`/
|
||||||
`consumeRequestedTab`), swipe actions.
|
`consumeRequestedTab`), swipe actions.
|
||||||
- [`FilesController`](../../lib/providers/files_controller.dart) — the
|
- [`FilesController`](../../lib/providers/files_controller.dart) — the
|
||||||
@@ -282,11 +283,13 @@ the sidebar):
|
|||||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
for *every* tab (previously only Files had shell-level top chrome, with
|
for *every* tab (previously only Files had shell-level top chrome, with
|
||||||
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
|
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
|
||||||
large title, an inline search field, a `plus` action on Files only (no
|
large title with Upload and the top account avatar on the same row,
|
||||||
other tab has a create/upload flow), and the account avatar; Android
|
and an inline search field below; Android
|
||||||
gets a compact title row with `search`/avatar actions, relying on an
|
gets a compact title row with `search`/avatar actions, relying on an
|
||||||
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
extended `NooFab` instead of a top-bar upload button. Both use the same
|
||||||
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
|
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
|
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
||||||
tab) and each tab plants its own as the first sliver in its own
|
tab) and each tab plants its own as the first sliver in its own
|
||||||
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
||||||
@@ -312,8 +315,13 @@ the sidebar):
|
|||||||
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||||
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||||
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||||
instead, a dropdown holding the same hidden-tabs + Settings content -
|
instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
|
||||||
see `styling.md`'s Gotchas for the wiring.
|
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,
|
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
||||||
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
||||||
(tab title, search, an "Upload" action on Files/Photos) over the same
|
(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
|
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||||
Flutter's own default to already be high enough.
|
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
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— see `standards.md` for why widget tests must mock both storage channels
|
||||||
rather than relying on this async path throwing naturally.
|
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
|
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
|
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.
|
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.
|
||||||
|
|||||||
+78
-48
@@ -86,7 +86,7 @@ Catalog:
|
|||||||
|
|
||||||
| Folder | Components |
|
| 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` |
|
| `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` |
|
| `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` |
|
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||||
@@ -123,7 +123,8 @@ Gotchas:
|
|||||||
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
||||||
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
||||||
track blends invisibly into the sheet instead of reading as a grouped
|
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()`
|
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
||||||
on its scrollable child, or pull-to-refresh silently can't be triggered
|
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) -
|
once the list is short enough to fit the viewport (empty, or one item) -
|
||||||
@@ -142,6 +143,19 @@ blocks are noted where they matter:
|
|||||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||||
Offline), and reused as-is by Favorites.
|
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
|
- Hidden files and external storage are each a three-way segmented row
|
||||||
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||||
in both the Files and Photos filter sheets), not toggles:
|
in both the Files and Photos filter sheets), not toggles:
|
||||||
@@ -149,7 +163,8 @@ blocks are noted where they matter:
|
|||||||
cloud (default) / external / all. Files and Photos each persist their own
|
cloud (default) / external / all. Files and Photos each persist their own
|
||||||
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||||
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||||
`StorageScope` stays shared. Leaving `hide` is still behind the
|
`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:
|
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
|
||||||
regular items first, then a collapsible "External storage" section
|
regular items first, then a collapsible "External storage" section
|
||||||
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
||||||
@@ -248,16 +263,23 @@ blocks are noted where they matter:
|
|||||||
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||||||
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||||||
don't confuse the two: this one is still non-blurred, just inset with a
|
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
|
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||||||
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||||
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
(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
|
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||||
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||||
"Frosted glass bottom bar", off by default, both platforms and both bar
|
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||||
styles) swaps the bar's solid `surface` for a 0.72-alpha one over a
|
styles) swaps the bar's solid `surface` for a translucent one over a
|
||||||
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops
|
`BackdropFilter` blur and drops
|
||||||
`nooDialogShadow` (it would show through the glass). A frosted bar only
|
`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
|
reads as glass with content behind it, so the shell uses
|
||||||
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||||
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||||
@@ -274,44 +296,50 @@ blocks are noted where they matter:
|
|||||||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||||
anywhere that needs the *current* cap on regular tabs.
|
anywhere that needs the *current* cap on regular tabs.
|
||||||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
|
||||||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
|
||||||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
a persisted Avatar position choice (`ui_avatar_position`, Top by default).
|
||||||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
`AvatarNavigationHost` owns one 420ms open / 320ms close animation using
|
||||||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
`easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
|
||||||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
its controls fade in after the expansion starts. Top moves the content pane
|
||||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
down through `AvatarNavigationBody`, keeping bottom navigation stationary.
|
||||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
Bottom moves the avatar upward into the card's bottom-right corner and
|
||||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
overlays the list immediately above navigation. Both use the Files gutter.
|
||||||
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
The floating rail beside a bottom avatar shows text only on the selected
|
||||||
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
|
||||||
the drawer. The menu header has a chevron that expands an account section
|
with the avatar inside the trailing end. Its popup expands upward with
|
||||||
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
full screen width, rounded top corners and no gap above the bar; the
|
||||||
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
avatar stays in the bar, and Search keeps its inline position.
|
||||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
The attached popup has no outer border or shadow, blending into the bar.
|
||||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
All bottom popups order navigation (including Settings), current account,
|
||||||
not a modal flow) - there's no existing anchored-popup primitive here
|
then expanded secondary accounts and Add/Manage Account controls.
|
||||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
The iOS shell places Upload and the top avatar on the large-title row.
|
||||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
|
||||||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
|
||||||
inset, not the top bar's full height on top of that) so it covers the
|
shows the plus-and-Upload button. Upload uses the folder icon's tonal
|
||||||
top bar - including the tab title - rather than sitting below it, and
|
`accentSoft` background and `accentText` foreground. Search stays below.
|
||||||
its card carries two stacked `boxShadow`s rather than just
|
Upload uses the compact 32px size to match the visible top avatar.
|
||||||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
On iOS the accent picker hides the unsupported wallpaper color option;
|
||||||
to read as basically invisible on a small card over a dark theme's
|
the accent subtitle, swatch and selection always reflect the seed color.
|
||||||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
|
||||||
edge to be visible against an already-dark backdrop), so a second,
|
A shared shell animation keeps separately mounted tab headers in sync
|
||||||
tighter, more opaque contact shadow underneath it gives real elevation
|
during Auto tab changes; reduced motion skips the transition.
|
||||||
in both themes. The card's border can go missing wherever an opaque row
|
Avatar navigation and its bottom satellite share the bottom bar's frost
|
||||||
sits against it too (every corner but the header's, which has no
|
toggle, blur and opacity, including throughout the popup animation.
|
||||||
full-bleed fill of its own) if a `Container` combines `border` with its
|
The setting is labelled "Frosted glass" and also controls both media
|
||||||
own `clipBehavior` - that paints the border as part of the *outer*
|
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
|
||||||
decoration, then the clipped child on top right up to the same boundary,
|
without a backdrop filter; On uses the shared blur and tint opacity.
|
||||||
with no gap for the border's own stroke to show through. Fixed the same
|
Search starts inside the rail when a bottom avatar occupies the satellite;
|
||||||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
its row slot smoothly collapses as Search fades into that satellite while
|
||||||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
the avatar moves into the popup. Closing reverses the same path. The card
|
||||||
reduced by that same 1px) around the filled, clipped content instead, so
|
retains hidden tabs, Settings, account switching, Add Account, and Manage
|
||||||
it never paints over the border. Also added
|
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
|
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||||||
second search row under the way iOS's `search:` slot does, so an inline
|
second search row under the way iOS's `search:` slot does, so an inline
|
||||||
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||||||
@@ -341,11 +369,13 @@ blocks are noted where they matter:
|
|||||||
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
||||||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||||
for the video player's seek bar, plus a perpetually-animated
|
for the non-iOS video player's seek bar, plus a perpetually-animated
|
||||||
`SeekBarPreview` wrapper used by the Settings style picker so every
|
`SeekBarPreview` wrapper used by the Settings style picker so every
|
||||||
preview always matches the real widget exactly (same painter, just fed
|
preview always matches the real widget exactly (same painter, just fed
|
||||||
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
||||||
forking the painter.
|
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
|
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
||||||
back button + filename, the bottom action bar, the video transport
|
back button + filename, the bottom action bar, the video transport
|
||||||
controls) all share one small hand-rolled icon-button pattern
|
controls) all share one small hand-rolled icon-button pattern
|
||||||
@@ -384,11 +414,11 @@ blocks are noted where they matter:
|
|||||||
|
|
||||||
## Text/markdown viewer
|
## 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.
|
`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.
|
`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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
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 */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -26,9 +55,27 @@
|
|||||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||||
remoteInfo = Runner;
|
remoteInfo = Runner;
|
||||||
};
|
};
|
||||||
|
850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = F9BE9B2F18B10324D7B84776;
|
||||||
|
remoteInfo = ShareExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase 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 */ = {
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -42,26 +89,59 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
807C81744907462A25A6C1F8 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -73,14 +153,62 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
2B7F16B0F0EA2C5A842ED860 /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0AE6F681A3100A461B4A5318 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */,
|
||||||
);
|
);
|
||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -100,6 +228,9 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
5DF0A9032A8500B67D6C89AE /* Shared */,
|
||||||
|
3664F08C433ECEB00D1C4BDB /* ShareExtension */,
|
||||||
|
A5B80AB7F4AB17F90B745D0D /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -108,6 +239,7 @@
|
|||||||
children = (
|
children = (
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
405A5408F4071B4D12F08B21 /* ShareExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -124,10 +256,35 @@
|
|||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
5055E53DC55C0BCABD5F04F4 /* Native */,
|
||||||
|
857A0E7637776A71AFBB0614 /* Runner.entitlements */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -152,6 +309,7 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
@@ -162,6 +320,7 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -171,6 +330,23 @@
|
|||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -209,6 +385,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
F9BE9B2F18B10324D7B84776 /* ShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -221,6 +398,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
4EDBC7D61EA0C0974DA27BD6 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -274,6 +458,24 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -284,6 +486,22 @@
|
|||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -295,6 +513,12 @@
|
|||||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = ShareExtension;
|
||||||
|
target = F9BE9B2F18B10324D7B84776 /* ShareExtension */;
|
||||||
|
targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
@@ -375,6 +599,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -437,6 +662,89 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
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 */ = {
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -554,6 +862,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -576,6 +885,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -624,6 +934,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
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 */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
@@ -7,10 +8,52 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> 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) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,26 @@
|
|||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<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>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<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 UIKit
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Runner
|
||||||
|
|
||||||
class RunnerTests: XCTestCase {
|
class RunnerTests: XCTestCase {
|
||||||
|
|
||||||
func testExample() {
|
func testFileURLEncodesEachSegment() {
|
||||||
// If you add code to the Runner application, consider adding tests here.
|
let url = WebDAV.fileURL(
|
||||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-6
@@ -22,6 +22,7 @@ import 'providers/shares_controller.dart';
|
|||||||
import 'providers/sync_status_controller.dart';
|
import 'providers/sync_status_controller.dart';
|
||||||
import 'providers/trash_controller.dart';
|
import 'providers/trash_controller.dart';
|
||||||
import 'services/pick_intent_service.dart';
|
import 'services/pick_intent_service.dart';
|
||||||
|
import 'services/share_account_service.dart';
|
||||||
import 'services/share_intent_service.dart';
|
import 'services/share_intent_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'theme/design_tokens.dart';
|
import 'theme/design_tokens.dart';
|
||||||
@@ -31,6 +32,7 @@ import 'views/share_upload_view.dart';
|
|||||||
import 'widgets/app_drawer.dart';
|
import 'widgets/app_drawer.dart';
|
||||||
import 'widgets/app_tab_view_builder.dart';
|
import 'widgets/app_tab_view_builder.dart';
|
||||||
import 'widgets/app_top_bar.dart';
|
import 'widgets/app_top_bar.dart';
|
||||||
|
import 'widgets/avatar_menu.dart';
|
||||||
import 'widgets/bottom_nav_bar.dart';
|
import 'widgets/bottom_nav_bar.dart';
|
||||||
import 'widgets/create_menu.dart';
|
import 'widgets/create_menu.dart';
|
||||||
import 'widgets/noo/core/noo_avatar.dart';
|
import 'widgets/noo/core/noo_avatar.dart';
|
||||||
@@ -58,7 +60,14 @@ void main() {
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||||
ChangeNotifierProvider(
|
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()),
|
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||||
// Before SyncStatusController, which follows its Files Cache rule
|
// Before SyncStatusController, which follows its Files Cache rule
|
||||||
@@ -202,9 +211,11 @@ class MainShellView extends StatefulWidget {
|
|||||||
|
|
||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView> {
|
||||||
late AppTab _currentTab;
|
late AppTab _currentTab;
|
||||||
|
final Set<AppTab> _selectionActiveTabs = {};
|
||||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||||
StreamSubscription<PickRequest>? _pickSub;
|
StreamSubscription<PickRequest>? _pickSub;
|
||||||
|
ShareAccountSync? _shareAccountSync;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -226,6 +237,13 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||||
_shareSub = ShareIntentService.onNewShare.listen(_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:
|
// Same cold-start-vs-already-running split as the share intent above:
|
||||||
// another app may have launched Noo as its GET_CONTENT picker.
|
// another app may have launched Noo as its GET_CONTENT picker.
|
||||||
PickIntentService.getPickRequest().then((request) {
|
PickIntentService.getPickRequest().then((request) {
|
||||||
@@ -310,6 +328,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
}
|
}
|
||||||
_shareSub?.cancel();
|
_shareSub?.cancel();
|
||||||
_pickSub?.cancel();
|
_pickSub?.cancel();
|
||||||
|
_shareAccountSync?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,13 +432,27 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
// null`).
|
// null`).
|
||||||
final tabStack = Stack(
|
final tabStack = Stack(
|
||||||
children: [
|
children: [
|
||||||
IndexedStack(
|
AvatarNavigationBody(
|
||||||
|
child: IndexedStack(
|
||||||
index: selectedIndex,
|
index: selectedIndex,
|
||||||
children: displayTabs
|
children: displayTabs
|
||||||
.map(
|
.map(
|
||||||
(tab) => buildAppTabView(
|
(tab) => buildAppTabView(
|
||||||
tab,
|
tab,
|
||||||
_scrollControllers[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
|
topBar: isDesktop || pickRequest != null
|
||||||
? null
|
? null
|
||||||
: AppTopBar(
|
: AppTopBar(
|
||||||
@@ -427,11 +460,14 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
tab: tab,
|
tab: tab,
|
||||||
searchInBottomBar: showBottomBarSearch,
|
searchInBottomBar: showBottomBarSearch,
|
||||||
navMenuStyle: settings.navMenuStyle,
|
navMenuStyle: settings.navMenuStyle,
|
||||||
|
avatarPosition: settings.avatarPosition,
|
||||||
|
uploadButtonStyle: settings.fabStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -532,13 +568,15 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
bottomBarStyle,
|
bottomBarStyle,
|
||||||
settings.bottomBarFrosted,
|
settings.bottomBarFrosted,
|
||||||
),
|
),
|
||||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
// Android-only extended Upload FAB - iOS uses the top bar's Upload
|
||||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||||
// rather than the Scaffold popping it fully in/out on every tab
|
// rather than the Scaffold popping it fully in/out on every tab
|
||||||
// switch - see NooFab's [collapsed].
|
// switch - see NooFab's [collapsed].
|
||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
pickRequest == null && navStyle == NooNavStyle.android
|
pickRequest == null &&
|
||||||
|
navStyle == NooNavStyle.android &&
|
||||||
|
!_selectionActiveTabs.contains(selectedTab)
|
||||||
? NooFab(
|
? NooFab(
|
||||||
collapsed: switch (settings.fabStyle) {
|
collapsed: switch (settings.fabStyle) {
|
||||||
FabStyle.auto => !canUpload,
|
FabStyle.auto => !canUpload,
|
||||||
@@ -549,14 +587,35 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
onTap: () => showCreateMenu(context),
|
onTap: () => showCreateMenu(context),
|
||||||
)
|
)
|
||||||
: null,
|
: 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(
|
bottomNavigationBar: BottomNavBar(
|
||||||
style: navStyle,
|
style: navStyle,
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
frosted: settings.bottomBarFrosted,
|
frosted: settings.bottomBarFrosted,
|
||||||
|
frostedBlur: settings.bottomBarFrostedBlur,
|
||||||
|
frostedOpacity: settings.bottomBarFrostedOpacity,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
|
avatarInBottomBar:
|
||||||
|
pickRequest == null &&
|
||||||
|
settings.navMenuStyle == NooNavMenuStyle.avatarMenu &&
|
||||||
|
settings.avatarPosition == AvatarPosition.bottom,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
final tappedTab = pinnedTabs[index];
|
final tappedTab = pinnedTabs[index];
|
||||||
if (tappedTab == _currentTab) {
|
if (tappedTab == _currentTab) {
|
||||||
@@ -596,7 +655,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && pickRequest != null) pick.cancelPick();
|
if (!didPop && pickRequest != null) pick.cancelPick();
|
||||||
},
|
},
|
||||||
child: scaffold,
|
child: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -677,6 +677,32 @@ class FilesController extends ChangeNotifier
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||||
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||||
/// saved before the three-way filter keeps its meaning. [key]s are
|
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
import 'files_controller.dart';
|
import 'files_controller.dart';
|
||||||
@@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
|
_syncBasePath ??= (await SyncService.baseDirectory())?.path;
|
||||||
// A sync can delete the very folder being browsed (deleted on the
|
// A sync can delete the very folder being browsed (deleted on the
|
||||||
// server) - step out to the nearest ancestor that still exists rather
|
// server) - step out to the nearest ancestor that still exists rather
|
||||||
// than showing a phantom empty folder.
|
// than showing a phantom empty folder.
|
||||||
@@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
Future<String?> localPathFor(NextcloudItem item) async {
|
Future<String?> localPathFor(NextcloudItem item) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return null;
|
if (accountId == null) return null;
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = item.path.startsWith('/')
|
final relPath = item.path.startsWith('/')
|
||||||
? item.path.substring(1)
|
? item.path.substring(1)
|
||||||
@@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
String accountId,
|
String accountId,
|
||||||
String folderPath,
|
String folderPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return [];
|
if (base == null) return [];
|
||||||
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
||||||
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// session-restore that failed, e.g. transient network trouble at cold
|
/// session-restore that failed, e.g. transient network trouble at cold
|
||||||
/// start), this still retries rather than no-op, since that's exactly the
|
/// start), this still retries rather than no-op, since that's exactly the
|
||||||
/// case LoginView's "Continue as" tile exists to recover from. Gated
|
/// 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
|
/// 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
|
/// tile) can surface a failure - e.g. a stored app password that no
|
||||||
/// longer works and needs the account removed/re-added.
|
/// longer works and needs the account removed/re-added.
|
||||||
@@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns login lock off, along with both of its sub-toggles (meaningless
|
/// Turns login lock off. Requires a successful auth first, same as turning
|
||||||
/// once the base lock is gone). Requires a successful auth first, same as
|
/// it on - otherwise anyone with momentary access to an unlocked phone
|
||||||
/// turning it on - otherwise anyone with momentary access to an unlocked
|
/// could just switch it off. Only this lock: the account-switching and
|
||||||
/// phone could just switch it off.
|
/// hidden-files locks are independent and stay as they are.
|
||||||
Future<bool> disableLoginLock() async {
|
Future<bool> disableLoginLock() async {
|
||||||
if (!_loginLockEnabled) return true;
|
if (!_loginLockEnabled) return true;
|
||||||
final confirmed = await AppLockService.authenticate(
|
final confirmed = await AppLockService.authenticate(
|
||||||
@@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
_loginLockEnabled = false;
|
_loginLockEnabled = false;
|
||||||
_lockAccountSwitching = false;
|
|
||||||
_lockHiddenFiles = false;
|
|
||||||
_isUnlocked = false;
|
_isUnlocked = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) {
|
prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false));
|
||||||
p.setBool(_prefLoginLockEnabled, false);
|
|
||||||
p.setBool(_prefLockAccountSwitching, false);
|
|
||||||
p.setBool(_prefLockHiddenFiles, false);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLockAccountSwitching(bool value) {
|
/// Turns the "unlock to switch accounts" gate on or off. Independent of
|
||||||
if (!_loginLockEnabled) return;
|
/// 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;
|
_lockAccountSwitching = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLockHiddenFiles(bool value) {
|
/// Same as [setLockAccountSwitching], for revealing hidden files.
|
||||||
if (!_loginLockEnabled) return;
|
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;
|
_lockHiddenFiles = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
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.
|
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||||
@@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prompts for auth if [gate] is on and login lock is configured;
|
/// Prompts for auth if [gate] is on; returns true immediately (no prompt)
|
||||||
/// returns true immediately (no prompt) otherwise. Shared by the
|
/// otherwise. Each gate stands on its own - it doesn't depend on login lock
|
||||||
/// account-switching gate above and [FilesController]'s/`PhotosController`'s
|
/// being on. Shared by the account-switching gate above and
|
||||||
/// hidden-files gates.
|
/// [FilesController]'s/`PhotosController`'s hidden-files gates.
|
||||||
Future<bool> passGate(bool gate, String reason) async {
|
Future<bool> passGate(bool gate, String reason) async {
|
||||||
if (!_loginLockEnabled || !gate) return true;
|
if (!gate) return true;
|
||||||
return AppLockService.authenticate(reason);
|
return AppLockService.authenticate(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
|||||||
/// in Settings.
|
/// in Settings.
|
||||||
enum SwipeAction { none, favorite, delete, share }
|
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]
|
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
||||||
/// always shows the label.
|
/// always shows the label.
|
||||||
enum FabStyle { auto, mini, expanded }
|
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
|
/// Thumb/track presets for the video player's seek bar, matching the four
|
||||||
/// combinations offered by other Material You media players: a Material 3
|
/// combinations offered by other Material You media players: a Material 3
|
||||||
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
/// 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]).
|
/// animated wave with a tick-mark thumb ([squiggly]).
|
||||||
enum MediaProgressBarStyle { classic, wavy, slim, 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
|
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
|
||||||
/// configuration, swipe actions. Independent of login state - these mean
|
/// configuration, swipe actions. Independent of login state - these mean
|
||||||
/// the same thing whether any account is signed in or not, unlike
|
/// 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 _prefUseDynamicColor = 'ui_use_dynamic_color';
|
||||||
static const _prefSeedColor = 'ui_seed_color';
|
static const _prefSeedColor = 'ui_seed_color';
|
||||||
static const _prefTabOrder = 'ui_tab_order';
|
static const _prefTabOrder = 'ui_tab_order';
|
||||||
|
static const _prefSelectionBarPosition = 'ui_selection_bar_position';
|
||||||
static const _prefSelectionActionOrder = 'ui_selection_action_order';
|
static const _prefSelectionActionOrder = 'ui_selection_action_order';
|
||||||
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
||||||
static const _prefDefaultTab = 'ui_default_tab';
|
static const _prefDefaultTab = 'ui_default_tab';
|
||||||
@@ -39,6 +69,9 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefFabStyle = 'ui_fab_style';
|
static const _prefFabStyle = 'ui_fab_style';
|
||||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
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 _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
@@ -52,13 +85,17 @@ class SettingsController extends ChangeNotifier {
|
|||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
|
AvatarPosition _avatarPosition = AvatarPosition.top;
|
||||||
bool _bottomBarFrosted = false;
|
bool _bottomBarFrosted = false;
|
||||||
|
double _frostedBlur = FrostedGlassPreset.standard.blur;
|
||||||
|
double _frostedOpacity = FrostedGlassPreset.standard.opacity;
|
||||||
bool _searchInBottomBar = false;
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
||||||
|
|
||||||
bool _tapTabToScrollTop = true;
|
bool _tapTabToScrollTop = true;
|
||||||
|
SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top;
|
||||||
|
|
||||||
List<AppTab> _tabOrder = AppTab.values.toList();
|
List<AppTab> _tabOrder = AppTab.values.toList();
|
||||||
Set<AppTab> _hiddenTabs = {};
|
Set<AppTab> _hiddenTabs = {};
|
||||||
@@ -98,12 +135,22 @@ class SettingsController extends ChangeNotifier {
|
|||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
bool get bottomBarFrosted => _bottomBarFrosted;
|
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;
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
bool get searchInBottomBar => _searchInBottomBar;
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
bool get amoledDark => _amoledDark;
|
bool get amoledDark => _amoledDark;
|
||||||
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
||||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||||
|
SelectionBarPosition get selectionBarPosition => _selectionBarPosition;
|
||||||
|
|
||||||
/// The cap a screen should actually enforce for the *regular*, user-
|
/// The cap a screen should actually enforce for the *regular*, user-
|
||||||
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
||||||
@@ -144,6 +191,11 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => ThemeMode.system,
|
orElse: () => ThemeMode.system,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_selectionBarPosition = SelectionBarPosition.values.firstWhere(
|
||||||
|
(position) =>
|
||||||
|
position.name == prefs.getString(_prefSelectionBarPosition),
|
||||||
|
orElse: () => SelectionBarPosition.top,
|
||||||
|
);
|
||||||
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
||||||
if (bottomBarStyleName != null) {
|
if (bottomBarStyleName != null) {
|
||||||
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
||||||
@@ -151,6 +203,10 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _bottomBarStyle,
|
orElse: () => _bottomBarStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_avatarPosition = AvatarPosition.values.firstWhere(
|
||||||
|
(position) => position.name == prefs.getString(_prefAvatarPosition),
|
||||||
|
orElse: () => AvatarPosition.top,
|
||||||
|
);
|
||||||
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
||||||
if (navMenuStyleName != null) {
|
if (navMenuStyleName != null) {
|
||||||
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
||||||
@@ -160,6 +216,15 @@ class SettingsController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_bottomBarFrosted =
|
_bottomBarFrosted =
|
||||||
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||||
|
_frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp(
|
||||||
|
minFrostedBlur,
|
||||||
|
maxFrostedBlur,
|
||||||
|
);
|
||||||
|
_frostedOpacity =
|
||||||
|
(prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp(
|
||||||
|
minFrostedOpacity,
|
||||||
|
maxFrostedOpacity,
|
||||||
|
);
|
||||||
_searchInBottomBar =
|
_searchInBottomBar =
|
||||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
@@ -311,6 +376,22 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
_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) {
|
void setBottomBarStyle(NooBottomBarStyle style) {
|
||||||
if (_bottomBarStyle == style) return;
|
if (_bottomBarStyle == style) return;
|
||||||
_bottomBarStyle = style;
|
_bottomBarStyle = style;
|
||||||
@@ -318,6 +399,27 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
_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) {
|
void setBottomBarFrosted(bool value) {
|
||||||
if (_bottomBarFrosted == value) return;
|
if (_bottomBarFrosted == value) return;
|
||||||
_bottomBarFrosted = value;
|
_bottomBarFrosted = value;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
|
||||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||||
@@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart';
|
|||||||
class AppLockService {
|
class AppLockService {
|
||||||
static final LocalAuthentication _auth = LocalAuthentication();
|
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
|
/// Whether this device can do *some* form of local auth - biometric
|
||||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||||
static Future<bool> isDeviceSupported() async {
|
static Future<bool> isDeviceSupported() async {
|
||||||
|
if (debugIsDeviceSupported != null) return debugIsDeviceSupported!();
|
||||||
try {
|
try {
|
||||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||||
if (canCheckBiometrics) return true;
|
if (canCheckBiometrics) return true;
|
||||||
@@ -24,6 +33,7 @@ class AppLockService {
|
|||||||
/// throws) on cancellation, failure, or any platform error, so callers
|
/// throws) on cancellation, failure, or any platform error, so callers
|
||||||
/// can treat every non-true result the same way: stay locked/blocked.
|
/// can treat every non-true result the same way: stay locked/blocked.
|
||||||
static Future<bool> authenticate(String reason) async {
|
static Future<bool> authenticate(String reason) async {
|
||||||
|
if (debugAuthenticate != null) return debugAuthenticate!(reason);
|
||||||
try {
|
try {
|
||||||
return await _auth.authenticate(
|
return await _auth.authenticate(
|
||||||
localizedReason: reason,
|
localizedReason: reason,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class DownloadService {
|
|||||||
.toList(),
|
.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';
|
import '../providers/session_controller.dart';
|
||||||
|
|
||||||
/// The "get this account's auth header, or bail" guard every native-
|
/// 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),
|
'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 'package:flutter/services.dart';
|
||||||
import '../models/pick_request.dart';
|
import '../models/pick_request.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
||||||
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
/// (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
|
/// Non-null if the app was launched cold as another app's file/photo
|
||||||
/// picker.
|
/// picker.
|
||||||
static Future<PickRequest?> getPickRequest() async {
|
static Future<PickRequest?> getPickRequest() async {
|
||||||
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getPickRequest',
|
'getPickRequest',
|
||||||
);
|
);
|
||||||
return result == null ? null : PickRequest.fromMap(result);
|
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
|
/// Emits whenever the app is asked to act as a picker while already
|
||||||
/// running.
|
/// running.
|
||||||
static Stream<PickRequest> get onNewPickRequest {
|
static Stream<PickRequest> get onNewPickRequest {
|
||||||
return _newPickChannel.receiveBroadcastStream().map(
|
return quietEvents(
|
||||||
|
_newPickChannel,
|
||||||
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ class PickIntentService {
|
|||||||
List<String> localPaths,
|
List<String> localPaths,
|
||||||
List<String> mimeTypes,
|
List<String> mimeTypes,
|
||||||
) {
|
) {
|
||||||
return _methodChannel.invokeMethod('finishPick', {
|
return invokeIfAvailable(_methodChannel, 'finishPick', {
|
||||||
'paths': localPaths,
|
'paths': localPaths,
|
||||||
'mimeTypes': mimeTypes,
|
'mimeTypes': mimeTypes,
|
||||||
});
|
});
|
||||||
@@ -42,6 +45,6 @@ class PickIntentService {
|
|||||||
|
|
||||||
/// Backs out of picking mode with no result, closing the picker.
|
/// Backs out of picking mode with no result, closing the picker.
|
||||||
static Future<void> cancelPick() {
|
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 'package:flutter/services.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
/// 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
|
/// 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
|
/// Whatever was shared to launch the app cold (empty if it was launched
|
||||||
/// normally, not via a share).
|
/// normally, not via a share).
|
||||||
static Future<List<SharedFileRef>> getInitialShare() async {
|
static Future<List<SharedFileRef>> getInitialShare() async {
|
||||||
final result = await _methodChannel.invokeMethod<List<dynamic>>(
|
final result = await invokeIfAvailable<List<dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getInitialShare',
|
'getInitialShare',
|
||||||
);
|
);
|
||||||
return (result ?? [])
|
return (result ?? [])
|
||||||
@@ -51,7 +53,7 @@ class ShareIntentService {
|
|||||||
|
|
||||||
/// Emits whenever another share arrives while the app is already running.
|
/// Emits whenever another share arrives while the app is already running.
|
||||||
static Stream<List<SharedFileRef>> get onNewShare {
|
static Stream<List<SharedFileRef>> get onNewShare {
|
||||||
return _newShareChannel.receiveBroadcastStream().map((event) {
|
return quietEvents(_newShareChannel, (event) {
|
||||||
return (event as List<dynamic>)
|
return (event as List<dynamic>)
|
||||||
.cast<Map<dynamic, dynamic>>()
|
.cast<Map<dynamic, dynamic>>()
|
||||||
.map(SharedFileRef.fromMap)
|
.map(SharedFileRef.fromMap)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../models/saved_account.dart';
|
|||||||
import '../providers/files_controller.dart';
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
import '../providers/sync_status_controller.dart';
|
import '../providers/sync_status_controller.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
||||||
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
||||||
@@ -104,13 +105,18 @@ class SyncService {
|
|||||||
)
|
)
|
||||||
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
||||||
final password = await store.readPassword(account.id);
|
final password = await store.readPassword(account.id);
|
||||||
if (password == null ||
|
if (password == null || config.paths.isEmpty) {
|
||||||
config.paths.isEmpty ||
|
|
||||||
config.intervalMinutes == null) {
|
|
||||||
await cancelAccount(account.id);
|
await cancelAccount(account.id);
|
||||||
continue;
|
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,
|
'accountId': account.id,
|
||||||
'serverUrl': account.serverUrl,
|
'serverUrl': account.serverUrl,
|
||||||
'username': account.username,
|
'username': account.username,
|
||||||
@@ -131,8 +137,17 @@ class SyncService {
|
|||||||
|
|
||||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||||
/// nothing left to sync).
|
/// 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
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
@@ -158,7 +173,7 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
||||||
await _channel.invokeMethod('syncNow', {
|
await invokeOrExplain(_channel, 'syncNow', 'Syncing', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -178,7 +193,8 @@ class SyncService {
|
|||||||
/// app start - the native bus only knows an account once a sync pass has
|
/// app start - the native bus only knows an account once a sync pass has
|
||||||
/// run in this process.
|
/// run in this process.
|
||||||
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_channel,
|
||||||
'getSyncStatus',
|
'getSyncStatus',
|
||||||
{'accountId': accountId},
|
{'accountId': accountId},
|
||||||
);
|
);
|
||||||
@@ -196,9 +212,8 @@ class SyncService {
|
|||||||
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
||||||
/// was restarted. This is a broadcast stream, so any number of Dart
|
/// was restarted. This is a broadcast stream, so any number of Dart
|
||||||
/// listeners share the one native subscription.
|
/// listeners share the one native subscription.
|
||||||
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
|
static final Stream<SyncStatusSnapshot> statusStream = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map(
|
|
||||||
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,7 +228,7 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
final authHeader = session.service?.authHeaders['Authorization'];
|
||||||
if (authHeader == null) throw Exception('Not logged in.');
|
if (authHeader == null) throw Exception('Not logged in.');
|
||||||
await _channel.invokeMethod('resolveConflict', {
|
await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', {
|
||||||
'accountId': conflict.accountId,
|
'accountId': conflict.accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -236,12 +251,23 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return;
|
if (accountId == null) return;
|
||||||
await _channel.invokeMethod('removeLocalSync', {
|
await invokeIfAvailable(_channel, 'removeLocalSync', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'path': path,
|
'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
|
/// The deterministic local mirror path for [remoteItemPath] under
|
||||||
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
||||||
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
||||||
@@ -251,7 +277,7 @@ class SyncService {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String remoteItemPath,
|
String remoteItemPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = remoteItemPath.startsWith('/')
|
final relPath = remoteItemPath.startsWith('/')
|
||||||
? remoteItemPath.substring(1)
|
? remoteItemPath.substring(1)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class UploadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startUpload', {
|
await invokeOrExplain(_channel, 'startUpload', 'Uploading', {
|
||||||
...args,
|
...args,
|
||||||
'files': filesJson,
|
'files': filesJson,
|
||||||
'remoteFolder': files.currentFolderPath,
|
'remoteFolder': files.currentFolderPath,
|
||||||
@@ -70,7 +70,8 @@ class UploadService {
|
|||||||
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
||||||
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
||||||
/// subscription per call, and the native side only keeps the latest one.
|
/// subscription per call, and the native side only keeps the latest one.
|
||||||
static final Stream<UploadCompletion> completions = _statusChannel
|
static final Stream<UploadCompletion> completions = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
|
(event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,8 +366,8 @@ class NooMotion {
|
|||||||
|
|
||||||
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
||||||
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
||||||
/// bar/search satellite (the only other thing in the app that's genuinely
|
/// bar/search satellite, and the floating selection action bar.
|
||||||
/// floating above other content). Nothing else should use a `BoxShadow`.
|
/// Nothing else should use a `BoxShadow`.
|
||||||
const nooDialogShadow = BoxShadow(
|
const nooDialogShadow = BoxShadow(
|
||||||
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
||||||
blurRadius: 48,
|
blurRadius: 48,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
|||||||
import '../widgets/noo/core/noo_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/selection_bar_overlay.dart';
|
||||||
import '../widgets/tabs/tab_state_slivers.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
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
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
final PreferredSizeWidget? topBar;
|
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
|
@override
|
||||||
State<FavoritesView> createState() => _FavoritesViewState();
|
State<FavoritesView> createState() => _FavoritesViewState();
|
||||||
@@ -72,10 +79,12 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(_isSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
|
widget.onSelectionChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A favorited folder switches to the Files tab, navigated there; a
|
/// A favorited folder switches to the Files tab, navigated there; a
|
||||||
@@ -483,6 +492,10 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selectionAtBottom =
|
||||||
|
_isSelecting &&
|
||||||
|
context.watch<SettingsController>().selectionBarPosition ==
|
||||||
|
SelectionBarPosition.bottom;
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final isDesktop = NooLayout.isDesktop(context);
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
final favoritesController = context.watch<FavoritesController>();
|
final favoritesController = context.watch<FavoritesController>();
|
||||||
@@ -513,9 +526,11 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
|
|
||||||
final contentSlivers = <Widget>[
|
final contentSlivers = <Widget>[
|
||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
|
if (!selectionAtBottom)
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: !_isSelecting,
|
pinned: true,
|
||||||
delegate: StickyHeaderDelegate(
|
delegate: StickyHeaderDelegate(
|
||||||
|
floating: _isSelecting,
|
||||||
height: _isSelecting ? 56 : 64,
|
height: _isSelecting ? 56 : 64,
|
||||||
child: _isSelecting
|
child: _isSelecting
|
||||||
? _buildSelectionBar(context, selectedItems)
|
? _buildSelectionBar(context, selectedItems)
|
||||||
@@ -646,7 +661,13 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
}, childCount: favorites.length),
|
}, childCount: favorites.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height:
|
||||||
|
bottomBarClearance(context) +
|
||||||
|
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -654,6 +675,10 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelecting) _clearSelection();
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
},
|
},
|
||||||
|
child: SelectionBarOverlay(
|
||||||
|
bar: selectionAtBottom
|
||||||
|
? _buildSelectionBar(context, selectedItems)
|
||||||
|
: null,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: colors.bg,
|
color: colors.bg,
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
@@ -676,6 +701,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
|
|
||||||
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
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() {
|
void _openDetails() {
|
||||||
if (NooLayout.isDesktop(context)) {
|
if (NooLayout.isDesktop(context)) {
|
||||||
DetailsSheet.show(context, _currentItem);
|
DetailsSheet.show(context, _currentItem);
|
||||||
@@ -229,6 +233,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_textActions.dispose();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.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
|
// 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
|
// 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
|
// next to the rest of the light-themed app. The floating top/action
|
||||||
// bars now follow the theme too (FrostedGlassContainer's own `surface`
|
// bars follow the theme and share the navigation frost preference.
|
||||||
// default), at a higher blur opacity to stay legible over arbitrary
|
|
||||||
// photo/video brightness underneath.
|
|
||||||
final stageColor = colors.bg;
|
final stageColor = colors.bg;
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
return MediaTextPreview(
|
||||||
item: widget.item,
|
item: widget.item,
|
||||||
session: session,
|
session: session,
|
||||||
|
controller: _textActions,
|
||||||
localPathResolver: widget.localPathResolver,
|
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/noo/overlays/noo_sheet.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/selection_bar_overlay.dart';
|
||||||
import '../widgets/tabs/tab_state_slivers.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
@@ -64,12 +65,14 @@ class FilesView extends StatefulWidget {
|
|||||||
/// This tab's own shell top bar, planted as its first sliver - see
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
final PreferredSizeWidget? topBar;
|
final PreferredSizeWidget? topBar;
|
||||||
|
final ValueChanged<bool>? onSelectionChanged;
|
||||||
|
|
||||||
const FilesView({
|
const FilesView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
this.offline = false,
|
this.offline = false,
|
||||||
this.topBar,
|
this.topBar,
|
||||||
|
this.onSelectionChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -337,10 +340,12 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(_isSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
|
widget.onSelectionChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Routes a tap on an item, in picking, selecting or plain-browsing mode
|
/// Routes a tap on an item, in picking, selecting or plain-browsing mode
|
||||||
@@ -652,6 +657,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selectionAtBottom =
|
||||||
|
_isSelecting &&
|
||||||
|
context.watch<SettingsController>().selectionBarPosition ==
|
||||||
|
SelectionBarPosition.bottom;
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
// Display prefs (grid/list, filters, sort) live on FilesController for
|
// Display prefs (grid/list, filters, sort) live on FilesController for
|
||||||
// both tabs; `browser` is the tab's own folder listing.
|
// both tabs; `browser` is the tab's own folder listing.
|
||||||
@@ -835,8 +844,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
child: _ExternalStorageHeader(
|
child: _ExternalStorageHeader(
|
||||||
count: externalItems.length,
|
count: externalItems.length,
|
||||||
expanded: _externalExpanded,
|
expanded: _externalExpanded,
|
||||||
onTap: () =>
|
onTap: () => setState(() => _externalExpanded = !_externalExpanded),
|
||||||
setState(() => _externalExpanded = !_externalExpanded),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_externalExpanded)
|
if (_externalExpanded)
|
||||||
@@ -854,9 +862,11 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
// (+ breadcrumbs), while selecting it's the selection bar (see
|
// (+ breadcrumbs), while selecting it's the selection bar (see
|
||||||
// `topRow` above): either way it's the one thing that always stays
|
// `topRow` above): either way it's the one thing that always stays
|
||||||
// at the very top of the scroll view.
|
// at the very top of the scroll view.
|
||||||
|
if (!selectionAtBottom)
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: true,
|
pinned: true,
|
||||||
delegate: StickyHeaderDelegate(
|
delegate: StickyHeaderDelegate(
|
||||||
|
floating: _isSelecting,
|
||||||
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
|
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
|
||||||
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
|
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
|
||||||
// Getting this wrong overflows the sliver header by exactly the
|
// Getting this wrong overflows the sliver header by exactly the
|
||||||
@@ -939,12 +949,17 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
// draws the body behind the bar instead (as does a frosted bar), so it
|
// 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
|
// needs the bar's own footprint added on top or the last row ends up
|
||||||
// under it.
|
// under it.
|
||||||
if (NooBottomBar.drawsBehindBody(
|
if (selectionAtBottom ||
|
||||||
|
NooBottomBar.drawsBehindBody(
|
||||||
context.watch<SettingsController>().bottomBarStyle,
|
context.watch<SettingsController>().bottomBarStyle,
|
||||||
context.watch<SettingsController>().bottomBarFrosted,
|
context.watch<SettingsController>().bottomBarFrosted,
|
||||||
))
|
))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: bottomBarClearance(context)),
|
child: SizedBox(
|
||||||
|
height:
|
||||||
|
bottomBarClearance(context) +
|
||||||
|
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -958,6 +973,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
browser.navigateUp();
|
browser.navigateUp();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
child: SelectionBarOverlay(
|
||||||
|
bar: selectionAtBottom
|
||||||
|
? _buildSelectionBar(context, selectedItems)
|
||||||
|
: null,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: colors.bg,
|
color: colors.bg,
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
@@ -994,6 +1013,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-11
@@ -19,7 +19,6 @@ import '../widgets/filter_mode_rows.dart';
|
|||||||
import '../widgets/noo/core/noo_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/noo/core/noo_chip.dart';
|
import '../widgets/noo/core/noo_chip.dart';
|
||||||
import '../widgets/noo/core/noo_segmented_control.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/files/noo_file_kind.dart';
|
||||||
import '../widgets/noo/lists/noo_grouped_list.dart';
|
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||||
import '../widgets/noo/lists/noo_selection_bar.dart';
|
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||||
@@ -31,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
|||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/selection_bar_overlay.dart';
|
||||||
import '../widgets/tabs/tab_state_slivers.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
@@ -58,8 +58,14 @@ class PhotosView extends StatefulWidget {
|
|||||||
/// This tab's own shell top bar, planted as its first sliver - see
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
final PreferredSizeWidget? topBar;
|
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
|
@override
|
||||||
State<PhotosView> createState() => _PhotosViewState();
|
State<PhotosView> createState() => _PhotosViewState();
|
||||||
@@ -82,10 +88,12 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(_isSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
|
widget.onSelectionChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
||||||
@@ -215,6 +223,10 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selectionAtBottom =
|
||||||
|
_isSelecting &&
|
||||||
|
context.watch<SettingsController>().selectionBarPosition ==
|
||||||
|
SelectionBarPosition.bottom;
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final isDesktop = NooLayout.isDesktop(context);
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
final photosController = context.watch<PhotosController>();
|
final photosController = context.watch<PhotosController>();
|
||||||
@@ -248,9 +260,11 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Sticky while browsing; once selecting, the selection bar takes over
|
// Sticky while browsing; once selecting, the selection bar takes over
|
||||||
// the same slot instead.
|
// the same slot instead.
|
||||||
|
if (!selectionAtBottom)
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: !_isSelecting,
|
pinned: true,
|
||||||
delegate: StickyHeaderDelegate(
|
delegate: StickyHeaderDelegate(
|
||||||
|
floating: _isSelecting,
|
||||||
height: _isSelecting ? 56 : 64,
|
height: _isSelecting ? 56 : 64,
|
||||||
child: _isSelecting
|
child: _isSelecting
|
||||||
? _buildSelectionBar(context, pick, selectedItems)
|
? _buildSelectionBar(context, pick, selectedItems)
|
||||||
@@ -360,7 +374,13 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// Clearance so the last row isn't hidden behind the nav bar,
|
// Clearance so the last row isn't hidden behind the nav bar,
|
||||||
// regardless of grid length - see `bottomBarClearance`'s own doc
|
// regardless of grid length - see `bottomBarClearance`'s own doc
|
||||||
// comment for why this has to be dynamic rather than a flat 100.
|
// 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(
|
return PopScope(
|
||||||
@@ -368,6 +388,10 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelecting) _clearSelection();
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
},
|
},
|
||||||
|
child: SelectionBarOverlay(
|
||||||
|
bar: selectionAtBottom
|
||||||
|
? _buildSelectionBar(context, pick, selectedItems)
|
||||||
|
: null,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: colors.bg,
|
color: colors.bg,
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
@@ -390,6 +414,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,18 +579,33 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
FilterSection(
|
||||||
children: [
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.heart,
|
icon: LucideIcons.heart,
|
||||||
label: const Text('Favorites only'),
|
title: 'Favorites',
|
||||||
trailing: NooToggle(
|
child: NooSegmentedControl<bool>(
|
||||||
checked: photos.showFavoritesOnly,
|
fill: true,
|
||||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
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',
|
||||||
),
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: true,
|
||||||
|
icon: LucideIcons.heart,
|
||||||
|
label: 'Favorites only',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
HiddenFilesFilterRow(
|
HiddenFilesFilterRow(
|
||||||
value: photos.hiddenFilter,
|
value: photos.hiddenFilter,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Widget buildAppTabView(
|
|||||||
AppTab tab,
|
AppTab tab,
|
||||||
ScrollController controller, {
|
ScrollController controller, {
|
||||||
PreferredSizeWidget? topBar,
|
PreferredSizeWidget? topBar,
|
||||||
|
ValueChanged<bool>? onSelectionChanged,
|
||||||
}) {
|
}) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case AppTab.files:
|
case AppTab.files:
|
||||||
@@ -33,11 +34,20 @@ Widget buildAppTabView(
|
|||||||
key: const ValueKey('files'),
|
key: const ValueKey('files'),
|
||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
topBar: topBar,
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
);
|
);
|
||||||
case AppTab.photos:
|
case AppTab.photos:
|
||||||
return PhotosView(scrollController: controller, topBar: topBar);
|
return PhotosView(
|
||||||
|
scrollController: controller,
|
||||||
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
|
);
|
||||||
case AppTab.favorites:
|
case AppTab.favorites:
|
||||||
return FavoritesView(scrollController: controller, topBar: topBar);
|
return FavoritesView(
|
||||||
|
scrollController: controller,
|
||||||
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
|
);
|
||||||
case AppTab.activity:
|
case AppTab.activity:
|
||||||
return ActivityView(scrollController: controller, topBar: topBar);
|
return ActivityView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.trash:
|
case AppTab.trash:
|
||||||
@@ -52,6 +62,7 @@ Widget buildAppTabView(
|
|||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
offline: true,
|
offline: true,
|
||||||
topBar: topBar,
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
import 'avatar_menu.dart';
|
import 'avatar_menu.dart';
|
||||||
import 'create_menu.dart';
|
import 'create_menu.dart';
|
||||||
import 'noo/nav/noo_top_bar.dart';
|
import 'noo/nav/noo_top_bar.dart';
|
||||||
|
import 'noo/core/noo_button.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
|
/// 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).
|
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
|
||||||
/// Built on [NooTopBar]:
|
/// Built on [NooTopBar]:
|
||||||
///
|
///
|
||||||
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
|
/// - iOS: large title, Upload button and account avatar in one row, then
|
||||||
/// create/upload flow) and the account avatar trailing, then the large
|
/// an inline search field that opens [SearchView]. Upload shares Android's
|
||||||
/// title, then an inline search field that opens [SearchView].
|
/// Auto/Collapsed/Expanded preference.
|
||||||
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
||||||
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
||||||
/// isn't in the bottom bar, an inline search-field-styled launcher
|
/// 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 AppTab tab;
|
||||||
final bool searchInBottomBar;
|
final bool searchInBottomBar;
|
||||||
final NooNavMenuStyle navMenuStyle;
|
final NooNavMenuStyle navMenuStyle;
|
||||||
|
final AvatarPosition avatarPosition;
|
||||||
|
final FabStyle uploadButtonStyle;
|
||||||
|
|
||||||
const AppTopBar({
|
const AppTopBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
required this.tab,
|
required this.tab,
|
||||||
this.searchInBottomBar = false,
|
this.searchInBottomBar = false,
|
||||||
this.navMenuStyle = NooNavMenuStyle.drawer,
|
this.navMenuStyle = NooNavMenuStyle.drawer,
|
||||||
|
this.avatarPosition = AvatarPosition.top,
|
||||||
|
this.uploadButtonStyle = FabStyle.auto,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get _ios => style == NooNavStyle.ios;
|
bool get _ios => style == NooNavStyle.ios;
|
||||||
bool get _showSearch => !searchInBottomBar;
|
bool get _showSearch => !searchInBottomBar;
|
||||||
|
bool get _uploadCollapsed => switch (uploadButtonStyle) {
|
||||||
|
FabStyle.auto => tab != AppTab.files && tab != AppTab.photos,
|
||||||
|
FabStyle.mini => true,
|
||||||
|
FabStyle.expanded => false,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => NooTopBar(
|
Size get preferredSize => NooTopBar(
|
||||||
style: style,
|
style: style,
|
||||||
title: tab.label,
|
title: tab.label,
|
||||||
|
iosActionsInTitleRow: true,
|
||||||
// A throwaway placeholder purely to make preferredSize's "is there a
|
// A throwaway placeholder purely to make preferredSize's "is there a
|
||||||
// search slot" check match what build() actually shows - never
|
// search slot" check match what build() actually shows - never
|
||||||
// inserted into the tree, so its content doesn't matter.
|
// inserted into the tree, so its content doesn't matter.
|
||||||
@@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
return NooTopBar(
|
return NooTopBar(
|
||||||
style: style,
|
style: style,
|
||||||
title: tab.label,
|
title: tab.label,
|
||||||
|
iosActionsInTitleRow: true,
|
||||||
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
||||||
? () => Scaffold.of(context).openDrawer()
|
? () => Scaffold.of(context).openDrawer()
|
||||||
: null,
|
: null,
|
||||||
@@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
? const ShellSearchLauncher()
|
? const ShellSearchLauncher()
|
||||||
: null,
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
if (_ios && tab == AppTab.files)
|
if (_ios)
|
||||||
NooTopBarButton(
|
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,
|
icon: LucideIcons.plus,
|
||||||
tooltip: 'Create',
|
expansion: progress,
|
||||||
onTap: () => showCreateMenu(context),
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ShellAvatarButton(
|
),
|
||||||
|
),
|
||||||
|
if (navMenuStyle != NooNavMenuStyle.avatarMenu ||
|
||||||
|
avatarPosition == AvatarPosition.top)
|
||||||
|
AvatarNavigationAnchor(
|
||||||
|
child: Builder(
|
||||||
|
builder: (anchorContext) => ShellAvatarButton(
|
||||||
hitBox: _ios ? 44 : 48,
|
hitBox: _ios ? 44 : 48,
|
||||||
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||||
? () => showAvatarMenu(context)
|
? () => showAvatarMenu(anchorContext)
|
||||||
: null,
|
: null,
|
||||||
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||||
? 'Menu'
|
? 'Menu'
|
||||||
: 'Accounts',
|
: 'Accounts',
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+546
-148
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter, lerpDouble;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -6,53 +7,85 @@ import '../providers/session_controller.dart';
|
|||||||
import '../providers/settings_controller.dart';
|
import '../providers/settings_controller.dart';
|
||||||
import '../providers/trash_controller.dart';
|
import '../providers/trash_controller.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'noo/core/noo_avatar.dart';
|
|
||||||
import '../views/login_view.dart';
|
import '../views/login_view.dart';
|
||||||
|
import 'noo/core/noo_avatar.dart';
|
||||||
import 'noo/core/noo_badge.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 'noo/lists/noo_settings_row.dart';
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
/// The dropdown [ShellAvatarButton] opens when
|
/// The shell supplies the expanding-card presentation. Standalone top bars
|
||||||
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] -
|
/// retain a dialog fallback (for screens outside the shell).
|
||||||
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs,
|
Future<void> showAvatarMenu(BuildContext context) async {
|
||||||
/// Settings), reached from the avatar instead of a hamburger-triggered
|
final scope = AvatarNavigationScope.maybeOf(context);
|
||||||
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/
|
if (scope != null) {
|
||||||
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming)
|
scope._host.openFrom(context);
|
||||||
/// barrier and a card anchored under the top bar rather than a modal
|
return;
|
||||||
/// 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
|
await showGeneralDialog<void>(
|
||||||
/// content column the way this needs to).
|
|
||||||
Future<void> showAvatarMenu(BuildContext context) {
|
|
||||||
return showGeneralDialog<void>(
|
|
||||||
context: context,
|
context: context,
|
||||||
barrierColor: Colors.transparent,
|
barrierColor: Colors.transparent,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: 'Close menu',
|
barrierLabel: 'Close menu',
|
||||||
transitionDuration: NooMotion.fast,
|
transitionDuration: NooMotion.base,
|
||||||
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
|
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(
|
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: ScaleTransition(
|
child: ScaleTransition(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
scale: Tween<double>(
|
scale: Tween(begin: .88, end: 1.0).animate(
|
||||||
begin: 0.96,
|
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
||||||
end: 1,
|
),
|
||||||
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
|
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarMenuContent extends StatefulWidget {
|
class _StandaloneAvatarCard extends StatefulWidget {
|
||||||
const _AvatarMenuContent();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
|
State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> {
|
||||||
bool _expanded = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -60,87 +93,20 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
final trashCount = context.watch<TrashController>().items.length;
|
final trashCount = context.watch<TrashController>().items.length;
|
||||||
final hiddenTabs = settings.tabOrder
|
void settingsTap() {
|
||||||
.where((t) => settings.hiddenTabs.contains(t))
|
onClose();
|
||||||
.toList();
|
|
||||||
|
|
||||||
void closeAndOpenSettings() {
|
|
||||||
Navigator.pop(context);
|
|
||||||
openSettings(context);
|
openSettings(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Align(
|
final header = InkWell(
|
||||||
alignment: Alignment.topCenter,
|
onTap: onExpand,
|
||||||
// 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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(NooSpace.md),
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
session.username,
|
session.username,
|
||||||
@@ -156,44 +122,38 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
serverHost(session.serverUrl),
|
serverHost(session.serverUrl),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: NooText.meta.copyWith(
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
color: colors.fg3,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
Icon(
|
||||||
NooAvatar(
|
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||||
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,
|
size: 20,
|
||||||
color: colors.fg2,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
AnimatedSize(
|
final accountSection = AnimatedSize(
|
||||||
duration: NooMotion.fast,
|
duration: NooMotion.base,
|
||||||
curve: NooMotion.ease,
|
curve: Curves.easeOutCubic,
|
||||||
alignment: Alignment.topCenter,
|
alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter,
|
||||||
child: _expanded
|
child: expanded
|
||||||
? Column(
|
? Column(
|
||||||
children: [
|
children: [
|
||||||
for (final account in session.accounts)
|
for (final account in session.accounts)
|
||||||
@@ -202,17 +162,12 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
name: account.username,
|
name: account.username,
|
||||||
host: serverHost(account.serverUrl),
|
host: serverHost(account.serverUrl),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
onClose();
|
||||||
session.switchAccount(account.id);
|
session.switchAccount(account.id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
NooSpace.md,
|
|
||||||
0,
|
|
||||||
NooSpace.md,
|
|
||||||
NooSpace.md,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
@@ -221,16 +176,12 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
icon: LucideIcons.userPlus,
|
icon: LucideIcons.userPlus,
|
||||||
label: 'Add Account',
|
label: 'Add Account',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final nav = Navigator.of(
|
final nav = Navigator.of(context);
|
||||||
context,
|
onClose();
|
||||||
);
|
|
||||||
nav.pop();
|
|
||||||
nav.push(
|
nav.push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) =>
|
||||||
const LoginView(
|
const LoginView(isAddingAccount: true),
|
||||||
isAddingAccount: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -240,7 +191,7 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
child: _AccountButton(
|
child: _AccountButton(
|
||||||
icon: LucideIcons.users,
|
icon: LucideIcons.users,
|
||||||
label: 'Manage Accounts',
|
label: 'Manage Accounts',
|
||||||
onTap: closeAndOpenSettings,
|
onTap: settingsTap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -249,11 +200,14 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const SizedBox(width: double.infinity),
|
: const SizedBox(width: double.infinity),
|
||||||
),
|
);
|
||||||
if (hiddenTabs.isNotEmpty) ...[
|
final rows = Column(
|
||||||
Divider(height: 1, color: colors.line),
|
mainAxisSize: MainAxisSize.min,
|
||||||
for (final tab in hiddenTabs)
|
children: [
|
||||||
|
if (!bottom) accountSection,
|
||||||
|
for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains))
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
icon: tab.icon,
|
icon: tab.icon,
|
||||||
label: Text(tab.label),
|
label: Text(tab.label),
|
||||||
trailing: tab == AppTab.trash && trashCount > 0
|
trailing: tab == AppTab.trash && trashCount > 0
|
||||||
@@ -263,25 +217,470 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
onClose();
|
||||||
settings.requestTab(tab);
|
settings.requestTab(tab);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
|
||||||
Divider(height: 1, color: colors.line),
|
Divider(height: 1, color: colors.line),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
icon: LucideIcons.settings,
|
icon: LucideIcons.settings,
|
||||||
label: const Text('Settings'),
|
label: const Text('Settings'),
|
||||||
onTap: closeAndOpenSettings,
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -331,8 +730,7 @@ class _OtherAccountRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
NooAvatar(initials: accountInitial(name), current: false, size: 32),
|
||||||
const SizedBox(width: 44),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import 'noo/nav/noo_bottom_bar.dart';
|
import 'noo/nav/noo_bottom_bar.dart';
|
||||||
|
import 'avatar_menu.dart';
|
||||||
|
|
||||||
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
|
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
|
||||||
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
|
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
|
||||||
@@ -17,20 +18,26 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
final bool frosted;
|
final bool frosted;
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
final VoidCallback? onSearchTap;
|
final VoidCallback? onSearchTap;
|
||||||
|
final bool avatarInBottomBar;
|
||||||
|
|
||||||
const BottomNavBar({
|
const BottomNavBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
this.frosted = false,
|
this.frosted = false,
|
||||||
|
this.frostedBlur = NooBottomBar.defaultFrostedBlur,
|
||||||
|
this.frostedOpacity = NooBottomBar.defaultFrostedOpacity,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
this.onSearchTap,
|
this.onSearchTap,
|
||||||
|
this.avatarInBottomBar = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,16 +46,34 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
style: style,
|
style: style,
|
||||||
barStyle: barStyle,
|
barStyle: barStyle,
|
||||||
frosted: frosted,
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final tab in tabs)
|
for (final tab in tabs)
|
||||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||||
],
|
],
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onSelected: onDestinationSelected,
|
onSelected: (index) {
|
||||||
|
AvatarNavigationScope.maybeOf(context)?.close();
|
||||||
|
onDestinationSelected(index);
|
||||||
|
},
|
||||||
searchDestination: onSearchTap == null
|
searchDestination: onSearchTap == null
|
||||||
? null
|
? null
|
||||||
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
|
: 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// The blurred/translucent chrome for the media viewer's overlay bars
|
/// Media viewer chrome that shares the navigation frost preference.
|
||||||
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
|
/// Turning frosting off produces an opaque themed surface without blur.
|
||||||
/// 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.
|
|
||||||
class FrostedGlassContainer extends StatelessWidget {
|
class FrostedGlassContainer extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
@@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
||||||
final BorderRadius? radius;
|
final BorderRadius? radius;
|
||||||
|
|
||||||
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
/// Blur sigma for the backdrop filter. Defaults to the shared frost setting.
|
||||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
final double? blurSigma;
|
||||||
final double blurSigma;
|
|
||||||
|
|
||||||
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed
|
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting.
|
||||||
/// value; pass an explicit value (e.g. from user settings) to make it
|
final double? opacity;
|
||||||
/// adjustable.
|
final bool? frosted;
|
||||||
final double opacity;
|
|
||||||
|
|
||||||
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
|
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
|
||||||
/// build time, so it always matches the active theme) - pass an explicit
|
/// build time, so it always matches the active theme) - pass an explicit
|
||||||
@@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
required this.child,
|
required this.child,
|
||||||
this.borderRadius = 0,
|
this.borderRadius = 0,
|
||||||
this.radius,
|
this.radius,
|
||||||
this.blurSigma = 20,
|
this.blurSigma,
|
||||||
this.opacity = 0.8,
|
this.opacity,
|
||||||
|
this.frosted,
|
||||||
this.color,
|
this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final shape = radius ?? BorderRadius.circular(borderRadius);
|
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||||
final tint = color ?? context.nooColors.surface;
|
final tint = color ?? context.nooColors.surface;
|
||||||
return ClipRRect(
|
final settings = context.watch<SettingsController?>();
|
||||||
borderRadius: shape,
|
final enabled = frosted ?? settings?.bottomBarFrosted ?? false;
|
||||||
child: BackdropFilter(
|
final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20;
|
||||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
final fill = Container(
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tint.withValues(alpha: opacity),
|
color: enabled
|
||||||
|
? tint.withValues(
|
||||||
|
alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72,
|
||||||
|
)
|
||||||
|
: tint,
|
||||||
borderRadius: shape,
|
borderRadius: shape,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
);
|
||||||
),
|
return ClipRRect(
|
||||||
|
borderRadius: shape,
|
||||||
|
child: enabled
|
||||||
|
? BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||||
|
child: fill,
|
||||||
|
)
|
||||||
|
: fill,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/design_tokens.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 }
|
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
|
||||||
|
|
||||||
@@ -37,6 +45,14 @@ class NooButton extends StatefulWidget {
|
|||||||
final bool disabled;
|
final bool disabled;
|
||||||
final VoidCallback? onTap;
|
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({
|
const NooButton({
|
||||||
super.key,
|
super.key,
|
||||||
this.variant = NooButtonVariant.primary,
|
this.variant = NooButtonVariant.primary,
|
||||||
@@ -46,6 +62,8 @@ class NooButton extends StatefulWidget {
|
|||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
this.disabled = false,
|
this.disabled = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.iconOnly = false,
|
||||||
|
this.expansion,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -87,18 +105,30 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final s = _sizes[widget.size]!;
|
final s = _sizes[widget.size]!;
|
||||||
final (bg, fg, border) = _colors(colors);
|
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(
|
final content = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
|
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.icon != null && widget.child != null)
|
||||||
if (widget.child != null)
|
SizedBox(width: s.gap * (widget.expansion ?? 1)),
|
||||||
DefaultTextStyle(
|
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),
|
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||||
child: widget.child!,
|
child: widget.child!,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,7 +137,9 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
||||||
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
|
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,
|
onTap: widget.disabled ? null : widget.onTap,
|
||||||
child: AnimatedScale(
|
child: AnimatedScale(
|
||||||
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
|
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
|
||||||
@@ -115,8 +147,20 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
curve: NooMotion.ease,
|
curve: NooMotion.ease,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: s.height,
|
height: s.height,
|
||||||
width: widget.fullWidth ? double.infinity : null,
|
width: widget.iconOnly
|
||||||
padding: _isTextVariant
|
? 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.zero
|
||||||
: EdgeInsets.only(
|
: EdgeInsets.only(
|
||||||
left: hasLeadingIcon ? s.px - 4 : s.px,
|
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
|
// of shrink-wrapping to it, which is what stretched this button
|
||||||
// edge-to-edge instead of sizing to its content (see NooFab's
|
// edge-to-edge instead of sizing to its content (see NooFab's
|
||||||
// doc comment for the same bug).
|
// doc comment for the same bug).
|
||||||
alignment: widget.fullWidth ? Alignment.center : null,
|
alignment: widget.fullWidth || widget.iconOnly
|
||||||
|
? Alignment.center
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
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
|
/// Replaces the bare row of Material `IconButton`s each screen used to
|
||||||
/// build ad hoc.
|
/// 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
|
/// 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
|
/// count, and the bulk actions. This sits in the *content* pane's own
|
||||||
/// pinned header slot on every platform (the shell's desktop toolbar,
|
/// pinned header slot on every platform (the shell's desktop toolbar,
|
||||||
@@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: colors.surface,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
boxShadow: const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final Widget label;
|
final Widget label;
|
||||||
final Widget? subtitle;
|
final Widget? subtitle;
|
||||||
|
|
||||||
/// Renders as `value` text + a trailing chevron - mutually exclusive with
|
/// Renders as `value` text + a trailing chevron - mutually exclusive with
|
||||||
/// [trailing] (a `NooToggle`, button, badge, etc.).
|
/// [trailing] (a `NooToggle`, button, badge, etc.).
|
||||||
final String? value;
|
final String? value;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
final bool danger;
|
final bool danger;
|
||||||
|
|
||||||
/// Accent-text action row, e.g. "Add account".
|
/// Accent-text action row, e.g. "Add account".
|
||||||
final bool accent;
|
final bool accent;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
const NooSettingsRow({
|
const NooSettingsRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
this.danger = false,
|
this.danger = false,
|
||||||
this.accent = false,
|
this.accent = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
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;
|
final minHeight = subtitle != null ? 60.0 : 52.0;
|
||||||
|
|
||||||
Widget? trail = trailing;
|
Widget? trail = trailing;
|
||||||
@@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value!,
|
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),
|
const SizedBox(width: 4),
|
||||||
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
|
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
|
||||||
@@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: colors.surface,
|
color: backgroundColor ?? colors.surface,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (icon != null) ...[
|
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),
|
const SizedBox(width: 14),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -14,18 +14,13 @@ const double _kFloatingPillTop = 8;
|
|||||||
const double _kAndroidPillWidth = 56;
|
const double _kAndroidPillWidth = 56;
|
||||||
const double _kAndroidPillHeight = 32;
|
const double _kAndroidPillHeight = 32;
|
||||||
|
|
||||||
/// Frosted glass: the surface fill's opacity and the backdrop blur sigma -
|
|
||||||
/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome.
|
|
||||||
const double _kFrostedAlpha = 0.72;
|
|
||||||
const double _kFrostedSigma = 20;
|
|
||||||
|
|
||||||
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
|
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
|
||||||
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
||||||
/// what order, is the caller's business; this only draws [destinations].
|
/// what order, is the caller's business; this only draws [destinations].
|
||||||
///
|
///
|
||||||
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
|
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
|
||||||
/// padding) of icon 24 over a 10px label. Active: accent-text, 600.
|
/// padding) of icon 24 over a 10px selected label. Active: accent-text, 600.
|
||||||
/// Idle: fg-3, 500. No ripple.
|
/// Idle: centered fg-3 icon, no label. No ripple.
|
||||||
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
|
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
|
||||||
/// icon over a 12px label (label only visible - not removed from layout,
|
/// 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
|
/// so the row never resizes - once selected). The active icon sits in a
|
||||||
@@ -71,22 +66,37 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
final bool frosted;
|
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 List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
final NooNavDestination? searchDestination;
|
final NooNavDestination? searchDestination;
|
||||||
final VoidCallback? onSearchTap;
|
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({
|
const NooBottomBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
this.frosted = false,
|
this.frosted = false,
|
||||||
|
this.frostedBlur = defaultFrostedBlur,
|
||||||
|
this.frostedOpacity = defaultFrostedOpacity,
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
this.searchDestination,
|
this.searchDestination,
|
||||||
this.onSearchTap,
|
this.onSearchTap,
|
||||||
|
this.avatarSatellite,
|
||||||
|
this.avatarMenuProgress = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
||||||
@@ -99,6 +109,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
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
|
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||||
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||||
/// frosted bar (there's nothing to blur otherwise).
|
/// frosted bar (there's nothing to blur otherwise).
|
||||||
@@ -120,7 +133,12 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
// Attached folds Search into the row itself (last item); floating
|
// Attached folds Search into the row itself (last item); floating
|
||||||
// gives it a separate satellite circle instead (built below), so the
|
// 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.
|
// 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
|
final row = ios
|
||||||
? _buildIosRow(trailingSearch: rowSearch)
|
? _buildIosRow(trailingSearch: rowSearch)
|
||||||
@@ -144,7 +162,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
|
final pill = frosted
|
||||||
|
? _blurred(pillBox, pillRadius, frostedBlur)
|
||||||
|
: pillBox;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -154,7 +174,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
16,
|
16,
|
||||||
NooBottomBar.floatingBottomMargin,
|
NooBottomBar.floatingBottomMargin,
|
||||||
),
|
),
|
||||||
child: hasSearch
|
child: avatarSatellite != null
|
||||||
|
? _withAvatar(pill, barHeight, hasSearch)
|
||||||
|
: hasSearch
|
||||||
// A fixed-height SizedBox, not just a Row with
|
// A fixed-height SizedBox, not just a Row with
|
||||||
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
||||||
// gives this widget a *loose* (unbounded-max) height
|
// gives this widget a *loose* (unbounded-max) height
|
||||||
@@ -176,6 +198,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
size: barHeight,
|
size: barHeight,
|
||||||
frosted: frosted,
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -192,38 +216,127 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return frosted ? _blurred(bar, BorderRadius.zero) : bar;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _fill(NooColors colors) => frosted
|
Color _fill(NooColors colors) => frosted
|
||||||
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
: colors.surface;
|
: 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}) {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width =
|
||||||
|
constraints.maxWidth /
|
||||||
|
(destinations.length +
|
||||||
|
(trailingSearch == null ? 0 : _rowSearchFraction));
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < destinations.length; i++)
|
for (var i = 0; i < destinations.length; i++)
|
||||||
Expanded(
|
SizedBox(
|
||||||
|
width: width,
|
||||||
child: _IosItem(
|
child: _IosItem(
|
||||||
destination: destinations[i],
|
destination: destinations[i],
|
||||||
selected: i == selectedIndex,
|
selected: i == selectedIndex,
|
||||||
|
selectedLabelOnly: true,
|
||||||
onTap: () => onSelected(i),
|
onTap: () => onSelected(i),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trailingSearch != null)
|
if (trailingSearch != null)
|
||||||
Expanded(
|
_searchRowItem(
|
||||||
child: _IosItem(
|
_IosItem(
|
||||||
destination: trailingSearch,
|
destination: trailingSearch,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
selectedLabelOnly: true,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
),
|
),
|
||||||
|
width,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAndroidRow(
|
Widget _buildAndroidRow(
|
||||||
@@ -232,7 +345,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
NooNavDestination? trailingSearch,
|
NooNavDestination? trailingSearch,
|
||||||
}) {
|
}) {
|
||||||
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
||||||
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
|
final itemCount =
|
||||||
|
destinations.length + (trailingSearch != null ? _rowSearchFraction : 0);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final itemWidth = constraints.maxWidth / itemCount;
|
final itemWidth = constraints.maxWidth / itemCount;
|
||||||
@@ -258,7 +372,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < destinations.length; i++)
|
for (var i = 0; i < destinations.length; i++)
|
||||||
Expanded(
|
SizedBox(
|
||||||
|
width: itemWidth,
|
||||||
child: _AndroidItem(
|
child: _AndroidItem(
|
||||||
destination: destinations[i],
|
destination: destinations[i],
|
||||||
selected: i == selectedIndex,
|
selected: i == selectedIndex,
|
||||||
@@ -267,13 +382,14 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trailingSearch != null)
|
if (trailingSearch != null)
|
||||||
Expanded(
|
_searchRowItem(
|
||||||
child: _AndroidItem(
|
_AndroidItem(
|
||||||
destination: trailingSearch,
|
destination: trailingSearch,
|
||||||
selected: false,
|
selected: false,
|
||||||
floating: floating,
|
floating: floating,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
),
|
),
|
||||||
|
itemWidth,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -286,10 +402,10 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
|
|
||||||
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||||
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
||||||
Widget _blurred(Widget child, BorderRadius radius) => ClipRRect(
|
Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect(
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma),
|
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -302,12 +418,16 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final double size;
|
final double size;
|
||||||
final bool frosted;
|
final bool frosted;
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
|
|
||||||
const _SearchSatellite({
|
const _SearchSatellite({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.size,
|
required this.size,
|
||||||
required this.frosted,
|
required this.frosted,
|
||||||
|
required this.frostedBlur,
|
||||||
|
required this.frostedOpacity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -331,7 +451,7 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: frosted
|
color: frosted
|
||||||
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
: colors.surface,
|
: colors.surface,
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -339,18 +459,22 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||||
);
|
);
|
||||||
return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc;
|
return frosted
|
||||||
|
? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur)
|
||||||
|
: disc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IosItem extends StatelessWidget {
|
class _IosItem extends StatelessWidget {
|
||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final bool selectedLabelOnly;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _IosItem({
|
const _IosItem({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
|
this.selectedLabelOnly = false,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,10 +485,13 @@ class _IosItem extends StatelessWidget {
|
|||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
|
label: selectedLabelOnly && !selected ? destination.label : null,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: selectedLabelOnly && !selected
|
||||||
|
? Center(child: Icon(destination.icon, size: 24, color: fg))
|
||||||
|
: Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
@@ -378,7 +505,9 @@ class _IosItem extends StatelessWidget {
|
|||||||
style: NooText.navLabel.copyWith(
|
style: NooText.navLabel.copyWith(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
height: 1,
|
height: 1,
|
||||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
fontWeight: selected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w500,
|
||||||
color: fg,
|
color: fg,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating }
|
|||||||
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
||||||
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
||||||
/// drops that icon entirely and repurposes the avatar button - already
|
/// drops that icon entirely and repurposes the avatar button - already
|
||||||
/// sitting top-right, already a tap target every user already knows - as
|
/// available at the top or bottom by user preference - as the one entry
|
||||||
/// the one entry point instead, opening a dropdown anchored below it (see
|
/// point instead, expanding into an anchored navigation card (see
|
||||||
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
||||||
/// sliding from the opposite edge. Applies on both platforms, since
|
/// sliding from the opposite edge. Applies on both platforms, since
|
||||||
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
/// [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
|
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
|
||||||
/// Android) through [IconTheme].
|
/// Android) through [IconTheme].
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
|
final bool iosActionsInTitleRow;
|
||||||
|
|
||||||
/// iOS only: the search field shown below the large title.
|
/// iOS only: the search field shown below the large title.
|
||||||
final Widget? search;
|
final Widget? search;
|
||||||
@@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
this.leading,
|
this.leading,
|
||||||
this.onMenu,
|
this.onMenu,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
|
this.iosActionsInTitleRow = false,
|
||||||
this.search,
|
this.search,
|
||||||
this.androidTitleTrailing,
|
this.androidTitleTrailing,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
@@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
@override
|
@override
|
||||||
Size get preferredSize {
|
Size get preferredSize {
|
||||||
if (!_ios) return const Size.fromHeight(_androidRow);
|
if (!_ios) return const Size.fromHeight(_androidRow);
|
||||||
|
if (iosActionsInTitleRow) {
|
||||||
|
return Size.fromHeight(
|
||||||
|
64 + (search != null ? _iosSearch + _iosSearchBottom : 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Size.fromHeight(
|
return Size.fromHeight(
|
||||||
_iosRow +
|
_iosRow +
|
||||||
_iosTitleTop +
|
_iosTitleTop +
|
||||||
@@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIos(NooColors colors, Widget? lead) {
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
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 '../../providers/settings_controller.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/lists/noo_tab_order_row.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 '../noo/noo_layout.dart';
|
||||||
import 'settings_section.dart';
|
|
||||||
|
|
||||||
/// Settings section: the priority order bulk actions (favorite, share,
|
/// Settings section: the priority order bulk actions (favorite, share,
|
||||||
/// download, ...) show in on the Files/Photos/Favorites multi-select
|
/// 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
|
/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
|
||||||
/// desktop, see its own doc comment) vs. behind its trailing "More" -
|
/// 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
|
/// 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 {
|
class SettingsActionBarSection extends StatelessWidget {
|
||||||
const SettingsActionBarSection({super.key});
|
const SettingsActionBarSection({super.key});
|
||||||
|
|
||||||
@@ -33,18 +35,30 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
final desktop = NooLayout.isDesktop(context);
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final orderList = _ActionOrderList(
|
final orderList = _ActionOrderList(
|
||||||
|
rounded: desktop,
|
||||||
order: settings.selectionActionOrder,
|
order: settings.selectionActionOrder,
|
||||||
onReorder: (oldIndex, newIndex) =>
|
onReorder: (oldIndex, newIndex) =>
|
||||||
_handleReorder(settings, 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;
|
final colors = context.nooColors;
|
||||||
|
|
||||||
if (!desktop) {
|
if (!desktop) {
|
||||||
return SettingsSection(
|
return NooGroupedList(
|
||||||
title: 'Action bar',
|
footer: const Text(_subtitle),
|
||||||
subtitle: _subtitle,
|
children: [positionRow, orderList],
|
||||||
children: [orderList],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||||
const SizedBox(height: NooSpace.md),
|
const SizedBox(height: NooSpace.md),
|
||||||
|
positionRow,
|
||||||
|
const SizedBox(height: NooSpace.md),
|
||||||
orderList,
|
orderList,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -79,8 +95,13 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
class _ActionOrderList extends StatelessWidget {
|
class _ActionOrderList extends StatelessWidget {
|
||||||
final List<SelectionActionKind> order;
|
final List<SelectionActionKind> order;
|
||||||
final void Function(int oldIndex, int newIndex) onReorder;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -105,12 +126,14 @@ class _ActionOrderList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return ClipRRect(
|
final rows = DecoratedBox(
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(color: colors.line),
|
decoration: BoxDecoration(color: colors.line),
|
||||||
child: list,
|
child: list,
|
||||||
),
|
);
|
||||||
|
if (!rounded) return rows;
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
child: rows,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart';
|
|||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/core/noo_segmented_control.dart';
|
import '../noo/core/noo_segmented_control.dart';
|
||||||
|
import '../noo/core/noo_slider.dart';
|
||||||
import '../noo/core/noo_toggle.dart';
|
import '../noo/core/noo_toggle.dart';
|
||||||
import '../noo/lists/noo_settings_row.dart';
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
import '../noo/nav/noo_nav_style.dart';
|
import '../noo/nav/noo_nav_style.dart';
|
||||||
@@ -55,16 +56,34 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
_BottomBarStyleRow(settings: settings),
|
_BottomBarStyleRow(settings: settings),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.glassWater,
|
icon: LucideIcons.glassWater,
|
||||||
label: const Text('Frosted glass bottom bar'),
|
label: const Text('Frosted glass'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'A translucent, blurred bottom bar - content scrolls behind it',
|
'Blurred bottom navigation, avatar menus and media viewer panels',
|
||||||
),
|
),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: settings.bottomBarFrosted,
|
checked: settings.bottomBarFrosted,
|
||||||
onChanged: settings.setBottomBarFrosted,
|
onChanged: settings.setBottomBarFrosted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||||
_NavMenuStyleRow(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(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
label: const Text('Search in bottom bar'),
|
label: const Text('Search in bottom bar'),
|
||||||
@@ -94,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
icon: LucideIcons.palette,
|
icon: LucideIcons.palette,
|
||||||
label: const Text('Accent color'),
|
label: const Text('Accent color'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
settings.useDynamicColor
|
settings.useDynamicColor &&
|
||||||
|
Theme.of(context).platform != TargetPlatform.iOS
|
||||||
? 'Matching your wallpaper'
|
? 'Matching your wallpaper'
|
||||||
: settings.seedColor == AppTheme.defaultAccent
|
: settings.seedColor == AppTheme.defaultAccent
|
||||||
? 'Default'
|
? 'Default'
|
||||||
: 'Custom color',
|
: 'Custom color',
|
||||||
),
|
),
|
||||||
trailing: _AccentSwatchDot(
|
trailing: _AccentSwatchDot(
|
||||||
color: settings.useDynamicColor ? null : settings.seedColor,
|
color:
|
||||||
|
settings.useDynamicColor &&
|
||||||
|
Theme.of(context).platform != TargetPlatform.iOS
|
||||||
|
? null
|
||||||
|
: settings.seedColor,
|
||||||
),
|
),
|
||||||
onTap: () => _openAccentPicker(context, settings),
|
onTap: () => _openAccentPicker(context, settings),
|
||||||
),
|
),
|
||||||
@@ -114,6 +138,7 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
onChanged: settings.setAmoledDark,
|
onChanged: settings.setAmoledDark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (Theme.of(context).platform != TargetPlatform.iOS)
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.waves,
|
icon: LucideIcons.waves,
|
||||||
label: const Text('Seek bar style'),
|
label: const Text('Seek bar style'),
|
||||||
@@ -166,6 +191,7 @@ class _ThemeRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<ThemeMode>(
|
NooSegmentedControl<ThemeMode>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.themeMode,
|
value: settings.themeMode,
|
||||||
onChanged: settings.setThemeMode,
|
onChanged: settings.setThemeMode,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -229,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<NooBottomBarStyle>(
|
NooSegmentedControl<NooBottomBarStyle>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.bottomBarStyle,
|
value: settings.bottomBarStyle,
|
||||||
onChanged: settings.setBottomBarStyle,
|
onChanged: settings.setBottomBarStyle,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -251,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
|
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
||||||
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
||||||
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
||||||
@@ -289,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<NooNavMenuStyle>(
|
NooSegmentedControl<NooNavMenuStyle>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.navMenuStyle,
|
value: settings.navMenuStyle,
|
||||||
onChanged: settings.setNavMenuStyle,
|
onChanged: settings.setNavMenuStyle,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -342,6 +482,7 @@ class _FabStyleRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<FabStyle>(
|
NooSegmentedControl<FabStyle>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.fabStyle,
|
value: settings.fabStyle,
|
||||||
onChanged: settings.setFabStyle,
|
onChanged: settings.setFabStyle,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -395,10 +536,14 @@ class _AccentSwatchDot extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
||||||
|
final useDynamicColor =
|
||||||
|
settings.useDynamicColor &&
|
||||||
|
Theme.of(context).platform != TargetPlatform.iOS;
|
||||||
Widget grid(NooColors colors) => Wrap(
|
Widget grid(NooColors colors) => Wrap(
|
||||||
spacing: 14,
|
spacing: 14,
|
||||||
runSpacing: 14,
|
runSpacing: 14,
|
||||||
children: [
|
children: [
|
||||||
|
if (Theme.of(context).platform != TargetPlatform.iOS)
|
||||||
_AccentSwatch(
|
_AccentSwatch(
|
||||||
isSelected: settings.useDynamicColor,
|
isSelected: settings.useDynamicColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -410,7 +555,7 @@ void _openAccentPicker(BuildContext context, SettingsController settings) {
|
|||||||
),
|
),
|
||||||
for (final color in AppTheme.seedColors)
|
for (final color in AppTheme.seedColors)
|
||||||
_AccentSwatch(
|
_AccentSwatch(
|
||||||
isSelected: !settings.useDynamicColor && settings.seedColor == color,
|
isSelected: !useDynamicColor && settings.seedColor == color,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
settings.setSeedColor(color);
|
settings.setSeedColor(color);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|||||||
@@ -125,11 +125,15 @@ class _CacheIntervalRow extends StatelessWidget {
|
|||||||
onChanged: (value) => onChanged(value.round()),
|
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(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'While the app is closed, synced files refresh at most every '
|
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).',
|
'15 min (an Android limit).',
|
||||||
style: NooText.meta.copyWith(color: colors.fg3),
|
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
|
/// Settings section 3: three independent locks - opening the app, switching
|
||||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
/// accounts, and revealing hidden files - each behind the device's own PIN/
|
||||||
/// biometric credential (see `AppLockService` - this app never stores or
|
/// 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 {
|
class SettingsSecuritySection extends StatelessWidget {
|
||||||
const SettingsSecuritySection({super.key});
|
const SettingsSecuritySection({super.key});
|
||||||
|
|
||||||
Future<void> _handleLoginLockChanged(
|
Future<void> _handleLockChanged(
|
||||||
BuildContext context,
|
BuildContext context, {
|
||||||
SessionController session,
|
required Future<bool> Function() change,
|
||||||
bool value,
|
required bool turningOn,
|
||||||
) async {
|
required String name,
|
||||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
}) async {
|
||||||
|
final success = await change();
|
||||||
if (!success && context.mounted) {
|
if (!success && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
value
|
turningOn
|
||||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||||
: 'Could not turn off login lock',
|
: 'Could not turn off $name',
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -64,10 +66,17 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.lock,
|
icon: LucideIcons.lock,
|
||||||
label: Text(biometricLabel(platform)),
|
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(
|
trailing: NooToggle(
|
||||||
checked: session.loginLockEnabled,
|
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(
|
NooSettingsRow(
|
||||||
@@ -76,7 +85,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.lockAccountSwitching,
|
checked: session.lockAccountSwitching,
|
||||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
onChanged: (value) => _handleLockChanged(
|
||||||
|
context,
|
||||||
|
change: () => session.setLockAccountSwitching(value),
|
||||||
|
turningOn: value,
|
||||||
|
name: 'account switching lock',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
@@ -85,7 +99,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.lockHiddenFiles,
|
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 {
|
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
final double height;
|
final double height;
|
||||||
final Widget child;
|
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
|
@override
|
||||||
double get minExtent => height;
|
double get minExtent => height;
|
||||||
@@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|||||||
// Colors.transparent` guards against the same M3 elevation-tint
|
// Colors.transparent` guards against the same M3 elevation-tint
|
||||||
// behavior even if this ever gets a non-zero elevation.
|
// behavior even if this ever gets a non-zero elevation.
|
||||||
return Material(
|
return Material(
|
||||||
color: context.nooColors.bg,
|
color: floating ? Colors.transparent : context.nooColors.bg,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
@@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
||||||
return oldDelegate.height != height || oldDelegate.child != child;
|
return oldDelegate.height != height ||
|
||||||
|
oldDelegate.child != child ||
|
||||||
|
oldDelegate.floating != floating;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart';
|
|||||||
import '../frosted_glass_container.dart';
|
import '../frosted_glass_container.dart';
|
||||||
import 'viewer_icon_button.dart';
|
import 'viewer_icon_button.dart';
|
||||||
|
|
||||||
/// The bottom panel overlaid on the media viewer: an optional transport row
|
/// A full-width media panel containing transport, actions and details.
|
||||||
/// ([transportControls], video only) above the action row
|
/// Its solid/frosted surface follows the shared appearance preference.
|
||||||
/// (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.
|
|
||||||
class MediaActionBar extends StatelessWidget {
|
class MediaActionBar extends StatelessWidget {
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final bool isBusy;
|
final bool isBusy;
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
||||||
import '../../models/nextcloud_item.dart';
|
import '../../models/nextcloud_item.dart';
|
||||||
import '../../providers/session_controller.dart';
|
import '../../providers/session_controller.dart';
|
||||||
import '../../theme/design_tokens.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
|
/// 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
|
/// 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 _kTopBarClearance = 72;
|
||||||
const double _kActionBarClearance = 112;
|
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
|
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
||||||
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
||||||
/// editable when online; a Save button appears once it has changed. Markdown
|
/// editable when online - after tapping Edit; every file opens read-only
|
||||||
/// files open rendered, with an Edit/Preview toggle. Opened
|
/// (Markdown rendered) with an Edit/Preview toggle - and Save appears once it
|
||||||
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
|
/// has changed. Both controls live in the viewer's
|
||||||
/// local copy has no server to write back to.
|
/// 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 {
|
class MediaTextPreview extends StatefulWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
final SessionController session;
|
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
|
/// When set, read bytes from the local path it resolves to instead of
|
||||||
/// fetching from the server - see
|
/// fetching from the server - see
|
||||||
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
||||||
@@ -33,6 +112,7 @@ class MediaTextPreview extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.session,
|
required this.session,
|
||||||
|
this.controller,
|
||||||
this.localPathResolver,
|
this.localPathResolver,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,12 +126,15 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
bool _editing = false;
|
bool _editing = false;
|
||||||
|
final _focus = FocusNode();
|
||||||
|
|
||||||
bool get _isMarkdown {
|
bool get _isMarkdown {
|
||||||
final name = widget.item.name.toLowerCase();
|
final name = widget.item.name.toLowerCase();
|
||||||
return name.endsWith('.md') || name.endsWith('.markdown');
|
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 _showRendered => _isMarkdown && !_editing;
|
||||||
|
|
||||||
bool get _readOnly => widget.localPathResolver != null;
|
bool get _readOnly => widget.localPathResolver != null;
|
||||||
@@ -60,16 +143,45 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller.addListener(() => setState(() {}));
|
widget.controller?._bind(_toggleEditing, _save);
|
||||||
|
_controller.addListener(() {
|
||||||
|
setState(() {});
|
||||||
|
_syncToolbar();
|
||||||
|
});
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.controller?._unbind();
|
||||||
|
_focus.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.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 {
|
Future<void> _load() async {
|
||||||
try {
|
try {
|
||||||
final List<int> bytes;
|
final List<int> bytes;
|
||||||
@@ -89,6 +201,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
final text = utf8.decode(bytes, allowMalformed: true);
|
final text = utf8.decode(bytes, allowMalformed: true);
|
||||||
_controller.text = text;
|
_controller.text = text;
|
||||||
setState(() => _saved = text);
|
setState(() => _saved = text);
|
||||||
|
_syncToolbar();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = e.toString());
|
if (mounted) setState(() => _error = e.toString());
|
||||||
@@ -99,6 +212,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final text = _controller.text;
|
final text = _controller.text;
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
|
_syncToolbar();
|
||||||
var ok = false;
|
var ok = false;
|
||||||
try {
|
try {
|
||||||
ok = await widget.session.service!.putBytes(
|
ok = await widget.session.service!.putBytes(
|
||||||
@@ -111,6 +225,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
_isSaving = false;
|
_isSaving = false;
|
||||||
if (ok) _saved = text;
|
if (ok) _saved = text;
|
||||||
});
|
});
|
||||||
|
_syncToolbar();
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
||||||
@@ -158,7 +273,10 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
padding: padding,
|
padding: padding,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
readOnly: _readOnly,
|
focusNode: _focus,
|
||||||
|
// Read-only until Edit is tapped (always, for the
|
||||||
|
// Offline copy).
|
||||||
|
readOnly: _readOnly || !_editing,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
style: style,
|
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:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
@@ -198,7 +199,9 @@ class VideoTransportControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
child: WavySeekBar(
|
child: Theme.of(context).platform == TargetPlatform.iOS
|
||||||
|
? IosSeekBar(controller: controller, color: fg)
|
||||||
|
: WavySeekBar(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
playedColor: fg,
|
playedColor: fg,
|
||||||
trackColor: fg.withValues(alpha: 0.3),
|
trackColor: fg.withValues(alpha: 0.3),
|
||||||
@@ -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
|
/// A Material You "expressive" wavy seek bar: the played portion of the
|
||||||
/// track animates as a travelling sine wave while playing and settles flat
|
/// track animates as a travelling sine wave while playing and settles flat
|
||||||
/// when paused, matching the Android 16+ media player style. Drag or tap
|
/// when paused, matching the Android 16+ media player style. Drag or tap
|
||||||
|
|||||||
@@ -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:convert';
|
||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/sync_status_controller.dart';
|
||||||
import 'package:noo/providers/trash_controller.dart';
|
import 'package:noo/providers/trash_controller.dart';
|
||||||
import 'package:noo/theme/app_theme.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/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';
|
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
|
||||||
/// Covers `SettingsController.navMenuStyle`'s two options as wired through
|
/// Covers `SettingsController.navMenuStyle`'s two options as wired through
|
||||||
@@ -84,7 +90,24 @@ void main() {
|
|||||||
Future<void> pumpTopBar(
|
Future<void> pumpTopBar(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required NooNavMenuStyle navMenuStyle,
|
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 {
|
}) 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));
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
@@ -108,15 +131,49 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false)
|
||||||
home: Scaffold(
|
.copyWith(
|
||||||
|
platform: style == NooNavStyle.ios
|
||||||
|
? TargetPlatform.iOS
|
||||||
|
: TargetPlatform.android,
|
||||||
|
),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final scaffold = Scaffold(
|
||||||
drawer: const Drawer(child: Text('drawer open')),
|
drawer: const Drawer(child: Text('drawer open')),
|
||||||
appBar: AppTopBar(
|
appBar: AppTopBar(
|
||||||
style: NooNavStyle.android,
|
style: style,
|
||||||
tab: AppTab.files,
|
tab: tab,
|
||||||
|
uploadButtonStyle: uploadStyle,
|
||||||
navMenuStyle: navMenuStyle,
|
navMenuStyle: navMenuStyle,
|
||||||
|
avatarPosition: position,
|
||||||
|
searchInBottomBar: search,
|
||||||
),
|
),
|
||||||
body: const SizedBox(),
|
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`:
|
// Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`:
|
||||||
// the active account's dependent controllers start periodic
|
// the active account's dependent controllers start periodic
|
||||||
// timers/futures that would spin it forever.
|
// 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));
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,4 +231,443 @@ void main() {
|
|||||||
expect(find.text('Add account'), findsOneWidget);
|
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,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_fab.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_progress_bar.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_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_segmented_control.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
||||||
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
|
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
|
||||||
@@ -98,6 +99,35 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () {
|
group('NooAvatar', () {
|
||||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||||
tester,
|
tester,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
@@ -62,6 +63,44 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testNooWidgets('frosted bar uses the given blur sigma and fill opacity', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 120,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
frosted: true,
|
||||||
|
frostedBlur: 31,
|
||||||
|
frostedOpacity: 0.5,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.widget<BackdropFilter>(find.byType(BackdropFilter)).filter,
|
||||||
|
ImageFilter.blur(sigmaX: 31, sigmaY: 31),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) =>
|
||||||
|
w is Container &&
|
||||||
|
w.decoration is BoxDecoration &&
|
||||||
|
(w.decoration as BoxDecoration).color ==
|
||||||
|
c.surface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('drawsBehindBody: floating or frosted', () {
|
test('drawsBehindBody: floating or frosted', () {
|
||||||
expect(
|
expect(
|
||||||
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
||||||
@@ -176,7 +215,7 @@ void main() {
|
|||||||
expect(opacity.opacity, 0);
|
expect(opacity.opacity, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
testNooWidgets('ios: unchanged icon-above-label layout, no indicator', (
|
testNooWidgets('ios: only selected tab has a label, no indicator', (
|
||||||
tester,
|
tester,
|
||||||
theme,
|
theme,
|
||||||
c,
|
c,
|
||||||
@@ -199,6 +238,8 @@ void main() {
|
|||||||
final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images));
|
final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images));
|
||||||
final labelCenter = tester.getCenter(find.text('Photos'));
|
final labelCenter = tester.getCenter(find.text('Photos'));
|
||||||
expect(labelCenter.dy, greaterThan(iconCenter.dy));
|
expect(labelCenter.dy, greaterThan(iconCenter.dy));
|
||||||
|
expect(find.text('Files'), findsNothing);
|
||||||
|
expect(find.text('Favorites'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
|
testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/widgets/selection_bar_overlay.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final extendsBody in [false, true]) {
|
||||||
|
for (final safeBottom in [0.0, 34.0]) {
|
||||||
|
testWidgets(
|
||||||
|
'action bar clears navigation: extend=$extendsBody, inset=$safeBottom',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.padding = FakeViewPadding(bottom: safeBottom);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
var taps = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
extendBody: extendsBody,
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: SizedBox(
|
||||||
|
key: const ValueKey('navigation'),
|
||||||
|
height: 64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SelectionBarOverlay(
|
||||||
|
bar: GestureDetector(
|
||||||
|
onTap: () => taps++,
|
||||||
|
child: Container(
|
||||||
|
key: const ValueKey('actions'),
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: 100,
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
SizedBox(height: 48, child: Text('Item $i')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final actions = find.byKey(const ValueKey('actions'));
|
||||||
|
final navTop = tester
|
||||||
|
.getTopLeft(find.byKey(const ValueKey('navigation')))
|
||||||
|
.dy;
|
||||||
|
expect(tester.getBottomLeft(actions).dy, navTop - 8);
|
||||||
|
final before = tester.getTopLeft(actions);
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -350));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getTopLeft(actions), before);
|
||||||
|
await tester.tap(actions);
|
||||||
|
expect(taps, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user