Compare commits
@@ -57,8 +57,8 @@ 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
|
||||||
|
|||||||
@@ -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,168 @@ 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` so far (`ios/Runner/Native/` + `ios/ShareExtension/`, see
|
||||||
|
below); for `pick_intent` and `sync_service` 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.
|
||||||
|
|
||||||
|
Not built on iOS yet: the picker (File Provider) and sync
|
||||||
|
(`BGTaskScheduler`) - see the iOS handoff notes.
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ widgets. Key points:
|
|||||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||||
|
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
|
||||||
|
`surface` (cards, selection bar, rows) from the container roles so it
|
||||||
|
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
|
||||||
|
`surfaceContainer` in dark. The dynamic_color package seeds the container
|
||||||
|
roles from the primary color rather than the OS palette, and in light mode
|
||||||
|
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
|
||||||
|
made cards blend into the page ("the selection bar has no background").
|
||||||
- **Font**: Inter via `google_fonts`, applied through
|
- **Font**: Inter via `google_fonts`, applied through
|
||||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
@@ -79,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` |
|
||||||
@@ -135,6 +142,18 @@ 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.
|
||||||
|
- Hidden files and external storage are each a three-way segmented row
|
||||||
|
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||||
|
in both the Files and Photos filter sheets), not toggles:
|
||||||
|
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
|
||||||
|
cloud (default) / external / all. Files and Photos each persist their own
|
||||||
|
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||||
|
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||||
|
`StorageScope` stays shared. Leaving `hide` is still behind the
|
||||||
|
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
|
||||||
|
regular items first, then a collapsible "External storage" section
|
||||||
|
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
||||||
|
holding the external ones; Photos/Favorites just merge them into one list.
|
||||||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||||
trail Files uses in place of the shared
|
trail Files uses in place of the shared
|
||||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||||
@@ -231,7 +250,25 @@ blocks are noted where they matter:
|
|||||||
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` (the app's one other shadow user - 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). The host `Scaffold` needs `extendBody: true` while it's active
|
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||||
|
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||||
|
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||||
|
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||||
|
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||||
|
styles) swaps the bar's solid `surface` for a translucent one over a
|
||||||
|
`BackdropFilter` blur and drops
|
||||||
|
`nooDialogShadow` (it would show through the glass). Strength is user-set:
|
||||||
|
`FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the
|
||||||
|
old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity
|
||||||
|
(0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown
|
||||||
|
only while frosted glass is on; both persist
|
||||||
|
(`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as
|
||||||
|
`NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once
|
||||||
|
the sliders are moved off all three. A frosted bar only
|
||||||
|
reads as glass with content behind it, so the shell uses
|
||||||
|
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||||
|
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||||
|
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
|
||||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||||
which also means every tab's own scrollable list has to reserve enough
|
which also means every tab's own scrollable list has to reserve enough
|
||||||
bottom padding to clear the bar - nothing does that automatically once the
|
bottom padding to clear the bar - nothing does that automatically once the
|
||||||
|
|||||||
@@ -109,3 +109,23 @@ it as your own app.
|
|||||||
|
|
||||||
Contributions are welcome under the terms in
|
Contributions are welcome under the terms in
|
||||||
[`CONTRIBUTING.md`](CONTRIBUTING.md).
|
[`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
### Why PolyForm Shield?
|
||||||
|
|
||||||
|
I want the code to be open to read, learn from, build, and improve, while
|
||||||
|
keeping anyone from lifting it and shipping a competing copy of the app.
|
||||||
|
Permissive licenses (MIT, Apache) allow exactly that, and copyleft ones
|
||||||
|
(GPL) still allow a rebranded fork. PolyForm Shield sits in between: it is
|
||||||
|
not an OSI "open source" license, and I'm not claiming it is, but it keeps
|
||||||
|
the code available while protecting the project.
|
||||||
|
|
||||||
|
## Where development happens
|
||||||
|
|
||||||
|
The source of truth is a self-hosted Gitea instance, and this GitHub
|
||||||
|
repository is a read-only mirror plus the public issue tracker. I keep it
|
||||||
|
that way so that I own my hosting, builds, and release credentials rather
|
||||||
|
than depending on a third party for them. You don't have to use Gitea:
|
||||||
|
open issues and pull requests here as usual. Open PRs are imported to my
|
||||||
|
instance, reviewed and merged there, and `main` is mirrored back here, so a
|
||||||
|
merged PR may show as closed with a link to the commit instead of "Merged".
|
||||||
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details.
|
||||||
|
|||||||
@@ -7,15 +7,36 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
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 */; };
|
||||||
|
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 */; };
|
||||||
|
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 */; };
|
||||||
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 */; };
|
||||||
|
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.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 */; };
|
||||||
|
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 +47,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 +81,51 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.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,6 +137,14 @@
|
|||||||
/* 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 = (
|
||||||
@@ -81,6 +153,44 @@
|
|||||||
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 */,
|
||||||
|
);
|
||||||
|
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 +210,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 +221,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 +238,20 @@
|
|||||||
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>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -152,6 +276,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 +287,7 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -171,6 +297,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 +352,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
F9BE9B2F18B10324D7B84776 /* ShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -221,6 +365,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;
|
||||||
@@ -277,6 +428,23 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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;
|
||||||
|
};
|
||||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -284,6 +452,15 @@
|
|||||||
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 */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -295,6 +472,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 */
|
||||||
@@ -360,7 +543,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -375,6 +558,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 +621,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 = {
|
||||||
@@ -486,7 +753,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -537,7 +804,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -554,6 +821,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 +844,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 +893,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,38 @@ 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()
|
||||||
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
return launched
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -53,6 +53,16 @@
|
|||||||
<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>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,186 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,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,19 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
/// Face ID / Touch ID with the device passcode as the fallback - the same
|
||||||
|
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
|
||||||
|
/// the share sheet asks for exactly what the app would.
|
||||||
|
enum DeviceAuth {
|
||||||
|
/// True only if the user actually authenticated; a cancel, a failure, or a
|
||||||
|
/// device with nothing to authenticate with all count as "not unlocked".
|
||||||
|
static func authenticate(reason: String) async -> Bool {
|
||||||
|
let context = LAContext()
|
||||||
|
var error: NSError?
|
||||||
|
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Noo</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
|
||||||
|
<key>NooKeychainAccessGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.ayushya.noo</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// State behind the share sheet's account and folder pickers.
|
||||||
|
@MainActor
|
||||||
|
final class ShareModel: ObservableObject {
|
||||||
|
enum Stage: Equatable {
|
||||||
|
case preparing
|
||||||
|
case chooseAccount
|
||||||
|
case picking
|
||||||
|
case uploading
|
||||||
|
case noAccount
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var stage: Stage = .preparing
|
||||||
|
@Published var items: [SharedItem] = []
|
||||||
|
@Published var path = "/"
|
||||||
|
@Published var isLoadingFolders = false
|
||||||
|
@Published var folderError: String?
|
||||||
|
/// A short explanation shown under the list - e.g. hidden folders stayed
|
||||||
|
/// hidden because the unlock was cancelled.
|
||||||
|
@Published var notice: String?
|
||||||
|
@Published private(set) var selected: SharedAccount?
|
||||||
|
|
||||||
|
/// The sheet's two toggles. They start at the app's own setting for the
|
||||||
|
/// chosen account and can be changed for this share only.
|
||||||
|
@Published private(set) var hiddenFilter: HiddenFilter = .hide
|
||||||
|
@Published private(set) var storageFilter: StorageFilter = .cloud
|
||||||
|
|
||||||
|
/// Every child folder of [path], unfiltered; the toggles filter it live.
|
||||||
|
@Published private var allFolders: [DavFolder] = []
|
||||||
|
/// Mount points of external storage seen so far. Only the mount's root is
|
||||||
|
/// flagged by the server, so anything under one of these is external too.
|
||||||
|
private var externalRoots: Set<String> = []
|
||||||
|
|
||||||
|
let shared: SharedAccounts?
|
||||||
|
|
||||||
|
/// Set by the view controller: what each button actually does.
|
||||||
|
var onUpload: () -> Void = {}
|
||||||
|
var onSaveForLater: () -> Void = {}
|
||||||
|
var onCancel: () -> Void = {}
|
||||||
|
|
||||||
|
init(shared: SharedAccounts?) {
|
||||||
|
self.shared = shared
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts: [SharedAccount] { shared?.accounts ?? [] }
|
||||||
|
var canChangeAccount: Bool { accounts.count > 1 }
|
||||||
|
var activeAccountId: String? { shared?.active?.id }
|
||||||
|
|
||||||
|
var currentFolderName: String {
|
||||||
|
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileCountText: String {
|
||||||
|
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Folders, filtered by the toggles
|
||||||
|
|
||||||
|
private func isExternal(_ folder: DavFolder) -> Bool {
|
||||||
|
folder.isExternal
|
||||||
|
|| externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleFolders: [DavFolder] {
|
||||||
|
allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal folders - and, for "All", the external ones are listed apart.
|
||||||
|
var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } }
|
||||||
|
var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } }
|
||||||
|
|
||||||
|
// MARK: - Accounts
|
||||||
|
|
||||||
|
/// With several accounts the list comes first; with one it goes straight
|
||||||
|
/// to its folders.
|
||||||
|
func start() async {
|
||||||
|
guard let shared, let first = shared.accounts.first else {
|
||||||
|
stage = .noAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none")
|
||||||
|
if shared.accounts.count > 1 {
|
||||||
|
stage = .chooseAccount
|
||||||
|
} else {
|
||||||
|
await select(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAccounts() {
|
||||||
|
notice = nil
|
||||||
|
stage = .chooseAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading to an account other than the one the app is on is "switching"
|
||||||
|
/// in the app's terms, so it asks for the same unlock when the app does.
|
||||||
|
/// The toggles start at that account's own app settings; hidden folders
|
||||||
|
/// are behind the app's "lock hidden files" unlock.
|
||||||
|
func select(_ account: SharedAccount) async {
|
||||||
|
guard let shared else { return }
|
||||||
|
notice = nil
|
||||||
|
var wantedHidden = HiddenFilter(raw: account.hiddenFilter)
|
||||||
|
|
||||||
|
// Every selection asks again, like the app does for every switch - no
|
||||||
|
// unlock is remembered. If one action needs both unlocks, one prompt
|
||||||
|
// covers both.
|
||||||
|
let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden)
|
||||||
|
if needs.needsPrompt {
|
||||||
|
let reason = needs.switchesAccount
|
||||||
|
? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders"
|
||||||
|
if await !DeviceAuth.authenticate(reason: reason) {
|
||||||
|
guard !needs.switchesAccount else {
|
||||||
|
notice = "Unlock to upload to a different account."
|
||||||
|
stage = .chooseAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wantedHidden = .hide
|
||||||
|
notice = "Hidden folders are locked, so they're not shown."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope)
|
||||||
|
selected = account
|
||||||
|
externalRoots = []
|
||||||
|
storageFilter = StorageFilter(raw: account.storageScope)
|
||||||
|
hiddenFilter = wantedHidden
|
||||||
|
stage = .picking
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggles
|
||||||
|
|
||||||
|
/// Turning hidden folders *on* is what the app locks; going back to
|
||||||
|
/// hiding them never exposes anything, and neither does switching
|
||||||
|
/// between the two revealing modes.
|
||||||
|
func setHiddenFilter(_ filter: HiddenFilter) async {
|
||||||
|
guard filter != hiddenFilter else { return }
|
||||||
|
if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true {
|
||||||
|
guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else {
|
||||||
|
notice = "Hidden folders are locked, so they're not shown."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notice = nil
|
||||||
|
hiddenFilter = filter
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStorageFilter(_ filter: StorageFilter) async {
|
||||||
|
guard filter != storageFilter else { return }
|
||||||
|
notice = nil
|
||||||
|
storageFilter = filter
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Browsing
|
||||||
|
|
||||||
|
/// A toggle can leave you inside a folder it now hides, so changing one
|
||||||
|
/// always returns to the top - same as [open] with "/".
|
||||||
|
func open(_ path: String) async {
|
||||||
|
guard let account = selected else { return }
|
||||||
|
self.path = path
|
||||||
|
isLoadingFolders = true
|
||||||
|
folderError = nil
|
||||||
|
defer { isLoadingFolders = false }
|
||||||
|
do {
|
||||||
|
allFolders = try await DavClient.listFolders(account: account, path: path)
|
||||||
|
externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path))
|
||||||
|
} catch {
|
||||||
|
allFolders = []
|
||||||
|
folderError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openParent() async {
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
await open(parent.isEmpty ? "/" : parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharePickerView: View {
|
||||||
|
@ObservedObject var model: ShareModel
|
||||||
|
@State private var externalExpanded = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
content
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Button("Cancel", action: model.onCancel)
|
||||||
|
Spacer()
|
||||||
|
Text("Upload to Noo").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
// Balances Cancel so the title stays centred.
|
||||||
|
Button("Cancel", action: {}).hidden()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch model.stage {
|
||||||
|
case .preparing:
|
||||||
|
centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") }
|
||||||
|
case .uploading:
|
||||||
|
centered {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Uploading \(model.fileCountText) in the background").font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .noAccount:
|
||||||
|
centered {
|
||||||
|
Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
case .failed(let message):
|
||||||
|
centered {
|
||||||
|
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
||||||
|
}
|
||||||
|
case .chooseAccount:
|
||||||
|
accountList
|
||||||
|
case .picking:
|
||||||
|
folderList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accountList: some View {
|
||||||
|
List {
|
||||||
|
Section(
|
||||||
|
header: Text("Upload \(model.fileCountText) to which account?"),
|
||||||
|
footer: model.notice.map { Text($0) }
|
||||||
|
) {
|
||||||
|
ForEach(model.accounts) { account in
|
||||||
|
Button {
|
||||||
|
Task { await model.select(account) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(account.displayName, systemImage: "person.crop.circle")
|
||||||
|
Spacer()
|
||||||
|
if account.id == model.activeAccountId {
|
||||||
|
Text("Active").font(.caption).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Folders
|
||||||
|
|
||||||
|
private var folderList: some View {
|
||||||
|
List {
|
||||||
|
Section(header: accountHeader, footer: model.notice.map { Text($0) }) {
|
||||||
|
filterRow
|
||||||
|
if model.path != "/" {
|
||||||
|
Button {
|
||||||
|
Task { await model.openParent() }
|
||||||
|
} label: {
|
||||||
|
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model.isLoadingFolders {
|
||||||
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
} else if let error = model.folderError {
|
||||||
|
Text(error).foregroundColor(.secondary)
|
||||||
|
} else if model.internalFolders.isEmpty && model.externalFolders.isEmpty {
|
||||||
|
Text("No folders here").foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(model.internalFolders, id: \.path) { folderRow($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "All" keeps external storage in its own group, like the app's list.
|
||||||
|
if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty {
|
||||||
|
Section {
|
||||||
|
DisclosureGroup(isExpanded: $externalExpanded) {
|
||||||
|
ForEach(model.externalFolders, id: \.path) { folderRow($0) }
|
||||||
|
} label: {
|
||||||
|
Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func folderRow(_ folder: DavFolder) -> some View {
|
||||||
|
Button {
|
||||||
|
Task { await model.open(folder.path) }
|
||||||
|
} label: {
|
||||||
|
Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two toggles, as menus - Hidden folders and External storage each
|
||||||
|
/// start at the app's setting and can be changed for this share.
|
||||||
|
private var filterRow: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Menu {
|
||||||
|
Picker("Hidden folders", selection: hiddenBinding) {
|
||||||
|
Text("Hide hidden folders").tag(HiddenFilter.hide)
|
||||||
|
Text("Only hidden folders").tag(HiddenFilter.only)
|
||||||
|
Text("Show all").tag(HiddenFilter.include)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
chip("eye", "Hidden", hiddenTitle)
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
Picker("External storage", selection: storageBinding) {
|
||||||
|
Text("Cloud only").tag(StorageFilter.cloud)
|
||||||
|
Text("Only external storage").tag(StorageFilter.external)
|
||||||
|
Text("All + external storage").tag(StorageFilter.all)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
chip("externaldrive", "Storage", storageTitle)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(_ icon: String, _ name: String, _ value: String) -> some View {
|
||||||
|
Label("\(name): \(value)", systemImage: icon)
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Color(.secondarySystemFill)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hiddenBinding: Binding<HiddenFilter> {
|
||||||
|
Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageBinding: Binding<StorageFilter> {
|
||||||
|
Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hiddenTitle: String {
|
||||||
|
switch model.hiddenFilter {
|
||||||
|
case .hide: return "Hide"
|
||||||
|
case .only: return "Only"
|
||||||
|
case .include: return "All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageTitle: String {
|
||||||
|
switch model.storageFilter {
|
||||||
|
case .cloud: return "Cloud"
|
||||||
|
case .external: return "External"
|
||||||
|
case .all: return "All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accountHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Text(model.selected?.displayName ?? "")
|
||||||
|
Spacer()
|
||||||
|
if model.canChangeAccount {
|
||||||
|
Button("Change account") { model.showAccounts() }
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var parentName: String {
|
||||||
|
let parent = (model.path as NSString).deletingLastPathComponent
|
||||||
|
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if model.stage == .picking {
|
||||||
|
Button(action: model.onUpload) {
|
||||||
|
Text("Upload \(model.fileCountText) to \(model.currentFolderName)")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(model.isLoadingFolders)
|
||||||
|
}
|
||||||
|
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
|
||||||
|
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func centered<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||||
|
VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is
|
||||||
|
/// chosen *inside* the sheet - an extension can't open its host app - so this
|
||||||
|
/// shows a folder picker, then starts a background upload and closes:
|
||||||
|
///
|
||||||
|
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
||||||
|
/// 2. Reads the accounts the app left in the shared Keychain
|
||||||
|
/// (`SharedAccountStore`) - asking which one first if there are several -
|
||||||
|
/// and lists folders over WebDAV (`DavClient`), honouring the app's
|
||||||
|
/// hidden-files filter and its unlock settings (`ShareModel`).
|
||||||
|
/// 3. "Upload" queues the files on a background session that outlives this
|
||||||
|
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
||||||
|
///
|
||||||
|
/// With no account, or if the user prefers, the files are instead left in the
|
||||||
|
/// inbox for the app to offer its own destination picker next time it opens
|
||||||
|
/// (with a notification saying so).
|
||||||
|
final class ShareViewController: UIViewController {
|
||||||
|
private var model: ShareModel!
|
||||||
|
private var batchDirectory: URL?
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
model = ShareModel(shared: SharedAccountStore.load())
|
||||||
|
model.onUpload = { [weak self] in self?.upload() }
|
||||||
|
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
||||||
|
model.onCancel = { [weak self] in self?.cancel() }
|
||||||
|
|
||||||
|
let host = UIHostingController(rootView: SharePickerView(model: model))
|
||||||
|
addChild(host)
|
||||||
|
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(host.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
host.didMove(toParent: self)
|
||||||
|
|
||||||
|
Task { await prepare() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Importing
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func prepare() async {
|
||||||
|
guard let root = AppGroup.containerURL else {
|
||||||
|
model.stage = .failed("Noo isn't set up to receive files yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let providers =
|
||||||
|
(extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? []
|
||||||
|
|
||||||
|
do {
|
||||||
|
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||||
|
batchDirectory = batch
|
||||||
|
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) {
|
||||||
|
if let item = try? await copy(provider, into: batch) { model.items.append(item) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !model.items.isEmpty else {
|
||||||
|
model.stage = .failed("Noo can only receive files and photos.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await model.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||||
|
/// callback, so the copy happens inside it.
|
||||||
|
private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
|
||||||
|
guard let url else {
|
||||||
|
return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent)
|
||||||
|
try FileManager.default.copyItem(at: url, to: destination)
|
||||||
|
let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0
|
||||||
|
let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType
|
||||||
|
continuation.resume(
|
||||||
|
returning: SharedItem(
|
||||||
|
path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func upload() {
|
||||||
|
guard let account = model.selected else { return }
|
||||||
|
do {
|
||||||
|
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||||
|
model.stage = .uploading
|
||||||
|
complete(after: 1.2)
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveForLater() {
|
||||||
|
guard let root = AppGroup.containerURL else { return cancel() }
|
||||||
|
do {
|
||||||
|
try SharedInbox.append(model.items, in: root)
|
||||||
|
notifyOpenNoo(count: model.items.count)
|
||||||
|
complete(after: 0)
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel() {
|
||||||
|
if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) }
|
||||||
|
extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func complete(after delay: TimeInterval) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
|
self.extensionContext?.completeRequest(returningItems: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyOpenNoo(count: Int) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body =
|
||||||
|
count == 1
|
||||||
|
? "Open Noo to choose where to upload 1 file."
|
||||||
|
: "Open Noo to choose where to upload \(count) files."
|
||||||
|
content.sound = .default
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A folder in the account's WebDAV tree. [path] is relative to the user's
|
||||||
|
/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root.
|
||||||
|
/// [isExternal] is true for an external-storage mount point (Nextcloud's
|
||||||
|
/// `nc:mount-type` = `external`) - only the mount's root carries it, its
|
||||||
|
/// children don't, so callers track mount roots to know what's inside one.
|
||||||
|
struct DavFolder: Equatable {
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
var isExternal = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud")
|
||||||
|
/// folders only (the default), only external-storage ones, or both.
|
||||||
|
enum StorageFilter: String {
|
||||||
|
case cloud, external, all
|
||||||
|
|
||||||
|
init(raw: String) {
|
||||||
|
self = StorageFilter(rawValue: raw) ?? .cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
func shows(isExternal: Bool) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .cloud: return !isExternal
|
||||||
|
case .external: return isExternal
|
||||||
|
case .all: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
|
||||||
|
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
|
||||||
|
/// list everything (`include`). A folder counts as hidden when it - or any
|
||||||
|
/// folder above it - starts with a dot, exactly as in the app.
|
||||||
|
enum HiddenFilter: String {
|
||||||
|
case hide, only, include
|
||||||
|
|
||||||
|
init(raw: String) {
|
||||||
|
self = HiddenFilter(rawValue: raw) ?? .hide
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isHidden(path: String) -> Bool {
|
||||||
|
path.split(separator: "/").contains { $0.hasPrefix(".") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func shows(path: String) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .hide: return !Self.isHidden(path: path)
|
||||||
|
case .only: return Self.isHidden(path: path)
|
||||||
|
case .include: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
||||||
|
/// networking), so it's unit-tested. Compiled into both targets.
|
||||||
|
enum DavFolderParser {
|
||||||
|
private struct Response {
|
||||||
|
var href = ""
|
||||||
|
var isCollection = false
|
||||||
|
var mountType = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Delegate: NSObject, XMLParserDelegate {
|
||||||
|
var responses: [Response] = []
|
||||||
|
private var current: Response?
|
||||||
|
private var text = ""
|
||||||
|
|
||||||
|
private func local(_ name: String) -> String {
|
||||||
|
name.split(separator: ":").last.map(String.init) ?? name
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||||
|
) {
|
||||||
|
switch local(elementName) {
|
||||||
|
case "response": current = Response()
|
||||||
|
case "href", "mount-type": text = ""
|
||||||
|
case "collection": current?.isCollection = true
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||||
|
text += string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?
|
||||||
|
) {
|
||||||
|
switch local(elementName) {
|
||||||
|
case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
case "response":
|
||||||
|
if let current { responses.append(current) }
|
||||||
|
current = nil
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms".
|
||||||
|
/// The server may live under a sub-path, so the files root is located by
|
||||||
|
/// its marker instead of assumed to start at the beginning.
|
||||||
|
static func relativePath(fromHref href: String) -> String? {
|
||||||
|
let decoded = href.removingPercentEncoding ?? href
|
||||||
|
let marker = "/remote.php/dav/files/"
|
||||||
|
guard let range = decoded.range(of: marker) else { return nil }
|
||||||
|
var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
guard !parts.isEmpty else { return nil }
|
||||||
|
parts.removeFirst() // the username
|
||||||
|
return "/" + parts.joined(separator: "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
|
||||||
|
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
|
||||||
|
/// always left out.
|
||||||
|
static func folders(
|
||||||
|
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
|
||||||
|
) -> [DavFolder] {
|
||||||
|
let delegate = Delegate()
|
||||||
|
let parser = XMLParser(data: xml)
|
||||||
|
parser.delegate = delegate
|
||||||
|
parser.parse()
|
||||||
|
|
||||||
|
let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
|
||||||
|
return delegate.responses
|
||||||
|
.filter(\.isCollection)
|
||||||
|
.compactMap { response -> DavFolder? in
|
||||||
|
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
|
||||||
|
let name = (path as NSString).lastPathComponent
|
||||||
|
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
|
||||||
|
return DavFolder(name: name, path: path, isExternal: response.mountType == "external")
|
||||||
|
}
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists folders over WebDAV for the Share Extension's picker, in the
|
||||||
|
/// foreground (the sheet is on screen while it runs).
|
||||||
|
enum DavClient {
|
||||||
|
private static let body = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"><d:prop><d:resourcetype/><nc:mount-type/></d:prop></d:propfind>
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Every child folder, hidden and external ones included - the sheet's
|
||||||
|
/// toggles filter that list in memory, so changing one doesn't refetch.
|
||||||
|
/// Two accounts can live on the same server, so a request must be
|
||||||
|
/// authenticated by its own `Authorization` header alone - never by a
|
||||||
|
/// session cookie or credential a previous account's request left behind
|
||||||
|
/// in the shared stores, or the second account could be answered as the
|
||||||
|
/// first.
|
||||||
|
private static let session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
|
||||||
|
else { throw TransferError(message: "Invalid server address.") }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PROPFIND"
|
||||||
|
request.setValue("1", forHTTPHeaderField: "Depth")
|
||||||
|
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = body.data(using: .utf8)
|
||||||
|
request.timeoutInterval = 20
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
NSLog("[DavClient] PROPFIND %@ as %@ -> %d", url.path, account.username, status)
|
||||||
|
guard status == 207 else {
|
||||||
|
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
|
||||||
|
}
|
||||||
|
return DavFolderParser.folders(from: data, excluding: path, hidden: .include)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Starts the Share Extension's uploads on a background `URLSession` that
|
||||||
|
/// outlives the extension. The extension process is torn down as soon as its
|
||||||
|
/// sheet closes; because the session has a `sharedContainerIdentifier` and
|
||||||
|
/// the same identifier is recreated by the app at launch
|
||||||
|
/// (`TransferManager.reconnect`), the *app* is what gets relaunched to
|
||||||
|
/// receive the results, post the summary notification and clean up.
|
||||||
|
/// Compiled into both targets.
|
||||||
|
enum ShareUpload {
|
||||||
|
static let sessionIdentifier = "dev.ayushya.noo.transfers.share"
|
||||||
|
|
||||||
|
static func configuration() -> URLSessionConfiguration {
|
||||||
|
let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
|
||||||
|
config.sharedContainerIdentifier = AppGroup.identifier
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
config.isDiscretionary = false
|
||||||
|
config.waitsForConnectivity = true
|
||||||
|
// Authenticate by the request's own header only (see DavClient.session).
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `PUT` for [item] into [remoteFolder] - shared by the extension's
|
||||||
|
/// [enqueue] and the app's own uploads so both build requests identically.
|
||||||
|
static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? {
|
||||||
|
guard
|
||||||
|
let url = WebDAV.fileURL(
|
||||||
|
serverUrl: account.serverUrl,
|
||||||
|
username: account.username,
|
||||||
|
remotePath: WebDAV.join(remoteFolder, item.name)
|
||||||
|
)
|
||||||
|
else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date {
|
||||||
|
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues every item (already copied into the App Group by the extension)
|
||||||
|
/// and returns once the system has accepted them.
|
||||||
|
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
// Small files can finish while the extension is still on screen; for
|
||||||
|
// those this delegate counts them. Whatever outlives the extension is
|
||||||
|
// reported to the app instead (see `TransferManager`).
|
||||||
|
let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil)
|
||||||
|
|
||||||
|
var tasks: [URLSessionUploadTask] = []
|
||||||
|
for item in items {
|
||||||
|
guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path))
|
||||||
|
task.taskDescription =
|
||||||
|
(try? JSONEncoder().encode(
|
||||||
|
TransferTaskInfo(
|
||||||
|
kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path)
|
||||||
|
)).flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
session.finishTasksAndInvalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferError: LocalizedError {
|
||||||
|
let message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// One signed-in account, as much of it as the Share Extension needs to list
|
||||||
|
/// folders and upload. Compiled into both targets.
|
||||||
|
struct SharedAccount: Codable, Equatable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let serverUrl: String
|
||||||
|
let username: String
|
||||||
|
/// `Basic ...` - the app password never leaves the Keychain as plain text.
|
||||||
|
let authHeader: String
|
||||||
|
/// Shown in the picker, e.g. "alice@cloud.example.com".
|
||||||
|
let displayName: String
|
||||||
|
/// The app's Files "hidden files" filter for this account - `hide` (the
|
||||||
|
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
|
||||||
|
/// follows it instead of having a setting of its own.
|
||||||
|
let hiddenFilter: String
|
||||||
|
/// The app's storage scope for this account - `cloud` (the default),
|
||||||
|
/// `external` or `all` (see `StorageFilter`).
|
||||||
|
let storageScope: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String, serverUrl: String, username: String, authHeader: String, displayName: String,
|
||||||
|
hiddenFilter: String = "hide", storageScope: String = "cloud"
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.serverUrl = serverUrl
|
||||||
|
self.username = username
|
||||||
|
self.authHeader = authHeader
|
||||||
|
self.displayName = displayName
|
||||||
|
self.hiddenFilter = hiddenFilter
|
||||||
|
self.storageScope = storageScope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerates data written before a field existed instead of failing the
|
||||||
|
/// whole decode (which would look like "signed out" to the extension).
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.init(
|
||||||
|
id: try c.decode(String.self, forKey: .id),
|
||||||
|
serverUrl: try c.decode(String.self, forKey: .serverUrl),
|
||||||
|
username: try c.decode(String.self, forKey: .username),
|
||||||
|
authHeader: try c.decode(String.self, forKey: .authHeader),
|
||||||
|
displayName: try c.decode(String.self, forKey: .displayName),
|
||||||
|
hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide",
|
||||||
|
storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the app publishes for the extension: every account that can
|
||||||
|
/// upload, which one is active in the app, and the app-lock settings the
|
||||||
|
/// extension has to honour. The app rewrites it whenever any of that changes.
|
||||||
|
struct SharedAccounts: Codable, Equatable {
|
||||||
|
var accounts: [SharedAccount]
|
||||||
|
var activeId: String?
|
||||||
|
/// Settings -> Security: the three independent locks. `loginLockEnabled`
|
||||||
|
/// (unlock to open the app) is carried for completeness; the extension's own
|
||||||
|
/// gates are the other two.
|
||||||
|
var loginLockEnabled: Bool
|
||||||
|
var lockAccountSwitching: Bool
|
||||||
|
var lockHiddenFiles: Bool
|
||||||
|
|
||||||
|
/// The account the app is currently using, else the first one.
|
||||||
|
var active: SharedAccount? {
|
||||||
|
accounts.first { $0.id == activeId } ?? accounts.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading to an account other than the active one is "switching" in
|
||||||
|
/// the app's terms, so it needs the same unlock.
|
||||||
|
var needsUnlockToSwitchAccount: Bool { lockAccountSwitching }
|
||||||
|
|
||||||
|
/// Showing hidden folders needs the same unlock the app asks for when you
|
||||||
|
/// turn hidden files on.
|
||||||
|
var needsUnlockForHidden: Bool { lockHiddenFiles }
|
||||||
|
|
||||||
|
/// What choosing [account] (with [hidden] folders to show) asks the user
|
||||||
|
/// to unlock. One action, one prompt - when both the account switch and
|
||||||
|
/// the hidden folders need it, a single authentication covers both.
|
||||||
|
func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock {
|
||||||
|
SelectionUnlock(
|
||||||
|
switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount,
|
||||||
|
showsHidden: hidden != .hide && needsUnlockForHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning hidden folders on (from `hide`) is what the app locks; going
|
||||||
|
/// back to `hide`, or between the two revealing modes, never asks.
|
||||||
|
func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool {
|
||||||
|
old == .hide && new != .hide && needsUnlockForHidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
|
||||||
|
/// extension are entitled to (`keychain-access-groups`). The group's full id
|
||||||
|
/// carries the signing team's prefix, so it's injected into each target's
|
||||||
|
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
|
||||||
|
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
|
||||||
|
/// then always agree on it, with or without a team.
|
||||||
|
enum SharedAccountStore {
|
||||||
|
private static let service = "dev.ayushya.noo.shared-accounts"
|
||||||
|
private static let account = "all"
|
||||||
|
|
||||||
|
static var accessGroup: String? {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func baseQuery() -> [String: Any] {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
if let group = accessGroup, !group.isEmpty {
|
||||||
|
query[kSecAttrAccessGroup as String] = group
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ value: SharedAccounts) throws {
|
||||||
|
let data = try JSONEncoder().encode(value)
|
||||||
|
clear()
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecValueData as String] = data
|
||||||
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load() -> SharedAccounts? {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
|
||||||
|
!decoded.accounts.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clear() {
|
||||||
|
SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
// The first version kept a single account under its own service name;
|
||||||
|
// don't leave that credential behind.
|
||||||
|
var legacy = baseQuery()
|
||||||
|
legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account"
|
||||||
|
legacy[kSecAttrAccount as String] = "active"
|
||||||
|
SecItemDelete(legacy as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`.
|
||||||
|
struct SelectionUnlock: Equatable {
|
||||||
|
let switchesAccount: Bool
|
||||||
|
let showsHidden: Bool
|
||||||
|
|
||||||
|
var needsPrompt: Bool { switchesAccount || showsHidden }
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The App Group both the app and its Share Extension belong to - the one
|
||||||
|
/// place they can exchange files.
|
||||||
|
enum AppGroup {
|
||||||
|
static let identifier = "group.dev.ayushya.noo"
|
||||||
|
|
||||||
|
static var containerURL: URL? {
|
||||||
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file the Share Extension copied into the group container.
|
||||||
|
struct SharedItem: Codable, Equatable {
|
||||||
|
let path: String
|
||||||
|
let name: String
|
||||||
|
let mimeType: String?
|
||||||
|
let size: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files the Share Extension received, waiting for the app to ask the user
|
||||||
|
/// where to upload them. The extension appends; the app consumes (reads and
|
||||||
|
/// clears) when it launches or becomes active. Compiled into both targets.
|
||||||
|
///
|
||||||
|
/// Layout under `<root>/SharedInbox/`: `pending.json` (the manifest) and one
|
||||||
|
/// `<batch-id>/` folder per share holding the copied files.
|
||||||
|
enum SharedInbox {
|
||||||
|
static let folderName = "SharedInbox"
|
||||||
|
private static let manifestName = "pending.json"
|
||||||
|
|
||||||
|
static func directory(in root: URL) -> URL {
|
||||||
|
root.appendingPathComponent(folderName, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh folder for one share's files.
|
||||||
|
static func makeBatchDirectory(in root: URL) throws -> URL {
|
||||||
|
let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
static func append(_ items: [SharedItem], in root: URL) throws {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
try withManifestLock(in: root) {
|
||||||
|
let existing = (try? readManifest(in: root)) ?? []
|
||||||
|
try writeManifest(existing + items, in: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything pending, dropping entries whose file has gone missing, and
|
||||||
|
/// clears the manifest so each share is delivered once.
|
||||||
|
static func consume(in root: URL) -> [SharedItem] {
|
||||||
|
var items: [SharedItem] = []
|
||||||
|
try? withManifestLock(in: root) {
|
||||||
|
items = ((try? readManifest(in: root)) ?? []).filter {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: manifestURL(in: root))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func manifestURL(in root: URL) -> URL {
|
||||||
|
directory(in: root).appendingPathComponent(manifestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readManifest(in root: URL) throws -> [SharedItem] {
|
||||||
|
let data = try Data(contentsOf: manifestURL(in: root))
|
||||||
|
return try JSONDecoder().decode([SharedItem].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func writeManifest(_ items: [SharedItem], in root: URL) throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory(in: root), withIntermediateDirectories: true)
|
||||||
|
try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extension and the app are separate processes that can both touch the
|
||||||
|
/// manifest, so read-modify-write goes through a file coordinator.
|
||||||
|
private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory(in: root), withIntermediateDirectories: true)
|
||||||
|
var coordinationError: NSError?
|
||||||
|
var thrown: Error?
|
||||||
|
NSFileCoordinator().coordinate(
|
||||||
|
writingItemAt: directory(in: root), options: [], error: &coordinationError
|
||||||
|
) { _ in
|
||||||
|
do { try body() } catch { thrown = error }
|
||||||
|
}
|
||||||
|
if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// The local notifications for an upload/download batch - one "Uploading 3
|
||||||
|
/// files…" when it starts, replaced in place by "Uploaded 3 files" when it
|
||||||
|
/// ends (both use the batch's id as the request identifier, so the second
|
||||||
|
/// *replaces* the first instead of stacking). Posted from whichever process
|
||||||
|
/// sees the event - the app, or the Share Extension while it's still alive.
|
||||||
|
/// iOS can't update a notification with live byte progress from a background
|
||||||
|
/// session, so start/finish is as much as a notification can say.
|
||||||
|
/// Compiled into both targets.
|
||||||
|
enum TransferNotifications {
|
||||||
|
static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) {
|
||||||
|
let verb = kind == .upload ? "Uploading" : "Downloading"
|
||||||
|
post(id: batch, body: "\(verb) \(files(count))…", sound: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func finished(_ batch: TransferBatch, id: String) {
|
||||||
|
let verb = batch.kind == .upload ? "upload" : "download"
|
||||||
|
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
|
||||||
|
let body: String
|
||||||
|
if batch.failed == 0 {
|
||||||
|
body = "\(past) \(files(batch.succeeded))"
|
||||||
|
} else if batch.succeeded == 0 {
|
||||||
|
body = "Couldn't \(verb) \(files(batch.failed))"
|
||||||
|
} else {
|
||||||
|
body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed"
|
||||||
|
}
|
||||||
|
post(id: id, body: body, sound: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func files(_ count: Int) -> String {
|
||||||
|
count == 1 ? "1 file" : "\(count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(id: String, body: String, sound: Bool) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body = body
|
||||||
|
if sound { content.sound = .default }
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counting one finished upload task - shared so the result is handled the
|
||||||
|
/// same way whether the Share Extension (still alive) or the app (relaunched
|
||||||
|
/// later) receives it.
|
||||||
|
enum UploadResults {
|
||||||
|
/// Deletes the task's staged copy, records its outcome, and - if it was the
|
||||||
|
/// last file of its batch - posts the summary notification and returns the
|
||||||
|
/// finished batch.
|
||||||
|
static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? {
|
||||||
|
guard let info = task.taskDescription
|
||||||
|
.flatMap({ $0.data(using: .utf8) })
|
||||||
|
.flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }),
|
||||||
|
info.kind == .upload
|
||||||
|
else { return nil }
|
||||||
|
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
|
||||||
|
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let ok = error == nil && (200..<300).contains(status)
|
||||||
|
let batch = TransferBatchStore.record(info, success: ok)
|
||||||
|
if let batch { TransferNotifications.finished(batch, id: info.batch) }
|
||||||
|
return (info, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate for the Share Extension's upload session while the extension is
|
||||||
|
/// still alive: files that finish before it closes are counted here, the
|
||||||
|
/// rest by the app (`TransferManager`) once the system relaunches it.
|
||||||
|
final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let result = UploadResults.handle(task, error: error) else { return }
|
||||||
|
// The app wasn't running to hear about this, so leave a note for it to
|
||||||
|
// refresh that folder next time it's active.
|
||||||
|
if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// What a background upload/download task is, stored in the task's
|
||||||
|
/// `taskDescription` so it's still known if the app was relaunched by the
|
||||||
|
/// system to deliver the result - possibly for a task the Share Extension
|
||||||
|
/// started. Compiled into both targets.
|
||||||
|
struct TransferTaskInfo: Codable {
|
||||||
|
enum Kind: String, Codable { case upload, download }
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
let batch: String
|
||||||
|
let name: String
|
||||||
|
let remoteFolder: String?
|
||||||
|
/// A file to delete once the task ends (the upload's staged copy).
|
||||||
|
let stagedPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running totals for one "upload these 3 files" / "download these 2 files"
|
||||||
|
/// request, so the one summary notification (and the Dart-side "folder
|
||||||
|
/// changed" event) fires exactly once, on the last file.
|
||||||
|
struct TransferBatch: Codable {
|
||||||
|
let kind: TransferTaskInfo.Kind
|
||||||
|
let remoteFolder: String?
|
||||||
|
let total: Int
|
||||||
|
var succeeded: Int
|
||||||
|
var failed: Int
|
||||||
|
|
||||||
|
var finished: Int { succeeded + failed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the
|
||||||
|
/// process's own: the Share Extension starts an upload batch, and the app -
|
||||||
|
/// a different process, possibly relaunched in the background - finishes
|
||||||
|
/// counting it.
|
||||||
|
enum TransferBatchStore {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
|
||||||
|
private static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: AppGroup.identifier) ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func key(_ id: String) -> String { "transfer.batch.\(id)" }
|
||||||
|
|
||||||
|
static func save(_ batch: TransferBatch, id: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let finishedFoldersKey = "transfer.finishedUploadFolders"
|
||||||
|
|
||||||
|
/// The Share Extension finished an upload into [folder] while the app
|
||||||
|
/// wasn't running; the app reads this on its next activation so its file
|
||||||
|
/// list can refresh.
|
||||||
|
static func noteFinishedUpload(folder: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
if !folders.contains(folder) { folders.append(folder) }
|
||||||
|
defaults.set(folders, forKey: finishedFoldersKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func consumeFinishedUploadFolders() -> [String] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
defaults.removeObject(forKey: finishedFoldersKey)
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one file's outcome. Returns the batch when that was its last
|
||||||
|
/// file (and forgets it), nil while others are still running.
|
||||||
|
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let storageKey = key(info.batch)
|
||||||
|
var batch =
|
||||||
|
defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }
|
||||||
|
?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0)
|
||||||
|
if success { batch.succeeded += 1 } else { batch.failed += 1 }
|
||||||
|
if batch.finished >= batch.total {
|
||||||
|
defaults.removeObject(forKey: storageKey)
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// URL building for a Nextcloud account's WebDAV files root. Pure functions
|
||||||
|
/// (no networking), so they're unit-tested in `RunnerTests`.
|
||||||
|
enum WebDAV {
|
||||||
|
/// `urlPathAllowed` minus "/", so a file name containing a slash-like
|
||||||
|
/// character can't introduce an extra path segment.
|
||||||
|
private static let segmentAllowed: CharacterSet = {
|
||||||
|
var set = CharacterSet.urlPathAllowed
|
||||||
|
set.remove(charactersIn: "/")
|
||||||
|
return set
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func encodeSegment(_ segment: String) -> String {
|
||||||
|
segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<server>/remote.php/dav/files/<user>/<remotePath>` - [remotePath] is
|
||||||
|
/// relative to the user's root, with or without a leading slash. A server
|
||||||
|
/// installed under a sub-path (`https://host/nextcloud`) keeps it.
|
||||||
|
static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? {
|
||||||
|
var base = serverUrl
|
||||||
|
while base.hasSuffix("/") { base.removeLast() }
|
||||||
|
let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) }
|
||||||
|
var url = base + "/remote.php/dav/files/" + encodeSegment(username)
|
||||||
|
if !segments.isEmpty { url += "/" + segments.joined(separator: "/") }
|
||||||
|
return URL(string: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `folder` + `name` with exactly one slash between them.
|
||||||
|
static func join(_ folder: String, _ name: String) -> String {
|
||||||
|
folder.hasSuffix("/") ? folder + name : folder + "/" + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocalFiles {
|
||||||
|
/// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then
|
||||||
|
/// "a (1).txt", "a (2).txt"... - so a second download of the same file
|
||||||
|
/// never overwrites the first.
|
||||||
|
static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL {
|
||||||
|
let candidate = directory.appendingPathComponent(name)
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) { return candidate }
|
||||||
|
let ext = (name as NSString).pathExtension
|
||||||
|
let stem = (name as NSString).deletingPathExtension
|
||||||
|
var index = 1
|
||||||
|
while true {
|
||||||
|
let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)"
|
||||||
|
let url = directory.appendingPathComponent(numbered)
|
||||||
|
if !fileManager.fileExists(atPath: url.path) { return url }
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import 'providers/shares_controller.dart';
|
|||||||
import 'providers/sync_status_controller.dart';
|
import 'providers/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';
|
||||||
@@ -36,7 +37,7 @@ import 'widgets/create_menu.dart';
|
|||||||
import 'widgets/noo/core/noo_avatar.dart';
|
import 'widgets/noo/core/noo_avatar.dart';
|
||||||
import 'widgets/noo/core/noo_button.dart';
|
import 'widgets/noo/core/noo_button.dart';
|
||||||
import 'widgets/noo/core/noo_fab.dart';
|
import 'widgets/noo/core/noo_fab.dart';
|
||||||
import 'widgets/noo/nav/noo_nav_style.dart';
|
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||||
import 'widgets/noo/noo_layout.dart';
|
import 'widgets/noo/noo_layout.dart';
|
||||||
@@ -58,7 +59,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
|
||||||
@@ -205,6 +213,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
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 +235,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 +326,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
}
|
}
|
||||||
_shareSub?.cancel();
|
_shareSub?.cancel();
|
||||||
_pickSub?.cancel();
|
_pickSub?.cancel();
|
||||||
|
_shareAccountSync?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,8 +543,12 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||||
// Floating needs the body to draw behind the bar's own transparent
|
// Floating needs the body to draw behind the bar's own transparent
|
||||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||||
// short of it like attached does.
|
// short of it like attached does; a frosted bar needs it too, or
|
||||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
// there'd be nothing behind it to blur.
|
||||||
|
extendBody: NooBottomBar.drawsBehindBody(
|
||||||
|
bottomBarStyle,
|
||||||
|
settings.bottomBarFrosted,
|
||||||
|
),
|
||||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
||||||
// 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,
|
||||||
@@ -549,6 +570,9 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
style: navStyle,
|
style: navStyle,
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ import 'session_controller.dart';
|
|||||||
|
|
||||||
enum FileSortField { name, dateCreated, dateModified, size }
|
enum FileSortField { name, dateCreated, dateModified, size }
|
||||||
|
|
||||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
/// external items in their own collapsible section then). Shared across
|
||||||
enum StorageScope { cloud, external }
|
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
|
||||||
|
enum StorageScope { cloud, external, all }
|
||||||
|
|
||||||
|
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
|
||||||
|
/// default), show [only] them, or [include] them alongside everything else.
|
||||||
|
/// Files and Photos each keep their own (see [applyCommonFilters]).
|
||||||
|
enum HiddenFilesFilter { hide, only, include }
|
||||||
|
|
||||||
/// The Files tab's files/folders/both filter — independent of and applied
|
/// The Files tab's files/folders/both filter — independent of and applied
|
||||||
/// after [StorageScope]/favorites/hidden filtering.
|
/// after [StorageScope]/favorites/hidden filtering.
|
||||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
|||||||
static const _prefStorageScope = 'ui_storage_scope';
|
static const _prefStorageScope = 'ui_storage_scope';
|
||||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||||
|
static const _prefHiddenFilter = 'ui_hidden_filter';
|
||||||
static const _prefFolderSort = 'ui_folder_sort';
|
static const _prefFolderSort = 'ui_folder_sort';
|
||||||
static const prefCachePolicy = 'ui_cache_policy';
|
static const prefCachePolicy = 'ui_cache_policy';
|
||||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
|||||||
bool _isGridView = false;
|
bool _isGridView = false;
|
||||||
StorageScope _storageScope = StorageScope.cloud;
|
StorageScope _storageScope = StorageScope.cloud;
|
||||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||||
bool _showHiddenFiles = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
|
|
||||||
final Map<String, FileSortField> _folderSortField = {};
|
final Map<String, FileSortField> _folderSortField = {};
|
||||||
final Map<String, bool> _folderSortAscending = {};
|
final Map<String, bool> _folderSortAscending = {};
|
||||||
@@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier
|
|||||||
bool get isGridView => _isGridView;
|
bool get isGridView => _isGridView;
|
||||||
StorageScope get storageScope => _storageScope;
|
StorageScope get storageScope => _storageScope;
|
||||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||||
bool get showHiddenFiles => _showHiddenFiles;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||||
|
|
||||||
@@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier
|
|||||||
List<NextcloudItem> applyCommonFilters(
|
List<NextcloudItem> applyCommonFilters(
|
||||||
List<NextcloudItem> source, {
|
List<NextcloudItem> source, {
|
||||||
required bool showFavoritesOnly,
|
required bool showFavoritesOnly,
|
||||||
required bool showHidden,
|
required HiddenFilesFilter hidden,
|
||||||
bool applyStorageScope = true,
|
bool applyStorageScope = true,
|
||||||
}) {
|
}) {
|
||||||
var filtered = source;
|
var filtered = source;
|
||||||
if (showFavoritesOnly) {
|
if (showFavoritesOnly) {
|
||||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||||
}
|
}
|
||||||
if (!showHidden) {
|
switch (hidden) {
|
||||||
|
case HiddenFilesFilter.hide:
|
||||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||||
|
case HiddenFilesFilter.only:
|
||||||
|
filtered = filtered.where(_isHiddenItem).toList();
|
||||||
|
case HiddenFilesFilter.include:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (applyStorageScope) {
|
if (applyStorageScope) {
|
||||||
filtered = filtered
|
switch (_storageScope) {
|
||||||
.where(
|
case StorageScope.cloud:
|
||||||
(item) => _storageScope == StorageScope.external
|
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||||
? item.isExternalStorage
|
case StorageScope.external:
|
||||||
: !item.isExternalStorage,
|
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||||
)
|
case StorageScope.all:
|
||||||
.toList();
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
@@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier
|
|||||||
// Files itself doesn't filter by favorite - that's the dedicated
|
// Files itself doesn't filter by favorite - that's the dedicated
|
||||||
// Favorites tab's job.
|
// Favorites tab's job.
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
showHidden: _showHiddenFiles,
|
hidden: _hiddenFilter,
|
||||||
applyStorageScope: applyStorageScope,
|
applyStorageScope: applyStorageScope,
|
||||||
);
|
);
|
||||||
switch (_filesTypeFilter) {
|
switch (_filesTypeFilter) {
|
||||||
@@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier
|
|||||||
_isGridView = false;
|
_isGridView = false;
|
||||||
_storageScope = StorageScope.cloud;
|
_storageScope = StorageScope.cloud;
|
||||||
_filesTypeFilter = FilesTypeFilter.all;
|
_filesTypeFilter = FilesTypeFilter.all;
|
||||||
_showHiddenFiles = false;
|
_hiddenFilter = HiddenFilesFilter.hide;
|
||||||
_cachePolicy = defaultCachePolicy;
|
_cachePolicy = defaultCachePolicy;
|
||||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier
|
|||||||
(f) => f.name == filesTypeFilterName,
|
(f) => f.name == filesTypeFilterName,
|
||||||
orElse: () => FilesTypeFilter.all,
|
orElse: () => FilesTypeFilter.all,
|
||||||
);
|
);
|
||||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
_hiddenFilter = loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
k(_prefHiddenFilter),
|
||||||
|
k(_prefShowHiddenFiles),
|
||||||
|
);
|
||||||
|
|
||||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||||
if (folderSortJson != null) {
|
if (folderSortJson != null) {
|
||||||
@@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||||
/// never exposes anything, so that direction is always allowed instantly.
|
/// never exposes anything, so that direction is always allowed instantly
|
||||||
Future<void> toggleShowHiddenFiles() async {
|
/// (and switching between the two revealing modes needs no second prompt).
|
||||||
if (!_showHiddenFiles) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -650,14 +669,61 @@ class FilesController extends ChangeNotifier
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHiddenFiles = !_showHiddenFiles;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenFiles,
|
_prefHiddenFilter,
|
||||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
(p, key) => p.setString(key, filter.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The storage scope saved for [accountId] (see [savedHiddenFilter]).
|
||||||
|
static StorageScope savedStorageScope(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String Function(String accountId, String baseKey) accountPrefKey,
|
||||||
|
String accountId,
|
||||||
|
) {
|
||||||
|
final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope));
|
||||||
|
return StorageScope.values.firstWhere(
|
||||||
|
(s) => s.name == name,
|
||||||
|
orElse: () => StorageScope.cloud,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hidden-files filter saved for [accountId] - not just the active
|
||||||
|
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
|
||||||
|
/// which follows each account's own setting.
|
||||||
|
static HiddenFilesFilter savedHiddenFilter(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String Function(String accountId, String baseKey) accountPrefKey,
|
||||||
|
String accountId,
|
||||||
|
) => loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
accountPrefKey(accountId, _prefHiddenFilter),
|
||||||
|
accountPrefKey(accountId, _prefShowHiddenFiles),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||||
|
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||||
|
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||||
|
/// already account-namespaced by the caller.
|
||||||
|
static HiddenFilesFilter loadHiddenFilter(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String key,
|
||||||
|
String legacyBoolKey,
|
||||||
|
) {
|
||||||
|
final name = prefs.getString(key);
|
||||||
|
if (name != null) {
|
||||||
|
return HiddenFilesFilter.values.firstWhere(
|
||||||
|
(f) => f.name == name,
|
||||||
|
orElse: () => HiddenFilesFilter.hide,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (prefs.getBool(legacyBoolKey) ?? false)
|
||||||
|
? HiddenFilesFilter.include
|
||||||
|
: HiddenFilesFilter.hide;
|
||||||
|
}
|
||||||
|
|
||||||
void _persistFolderSort() {
|
void _persistFolderSort() {
|
||||||
final combined = <String, dynamic>{};
|
final combined = <String, dynamic>{};
|
||||||
for (final path in {
|
for (final path in {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
|
|
||||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||||
|
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||||
static const _prefSortField = 'ui_sort_field';
|
static const _prefSortField = 'ui_sort_field';
|
||||||
static const _prefSortAscending = 'ui_sort_ascending';
|
static const _prefSortAscending = 'ui_sort_ascending';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
bool _showFavoritesOnly = false;
|
bool _showFavoritesOnly = false;
|
||||||
bool _showHidden = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
FileSortField _sortField = FileSortField.name;
|
FileSortField _sortField = FileSortField.name;
|
||||||
bool _sortAscending = true;
|
bool _sortAscending = true;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||||
bool get showHidden => _showHidden;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get sortField => _sortField;
|
FileSortField get sortField => _sortField;
|
||||||
bool get sortAscending => _sortAscending;
|
bool get sortAscending => _sortAscending;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
final filtered = files.applyCommonFilters(
|
final filtered = files.applyCommonFilters(
|
||||||
media,
|
media,
|
||||||
showFavoritesOnly: _showFavoritesOnly,
|
showFavoritesOnly: _showFavoritesOnly,
|
||||||
showHidden: _showHidden,
|
hidden: _hiddenFilter,
|
||||||
)..sort((a, b) => _compare(a, b));
|
)..sort((a, b) => _compare(a, b));
|
||||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||||
_showFavoritesOnly =
|
_showFavoritesOnly =
|
||||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
k(_prefHiddenFilterPhotos),
|
||||||
|
k(_prefShowHiddenPhotos),
|
||||||
|
);
|
||||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||||
_sortField = FileSortField.values.firstWhere(
|
_sortField = FileSortField.values.firstWhere(
|
||||||
(f) => f.name == sortFieldName,
|
(f) => f.name == sortFieldName,
|
||||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShowHidden() async {
|
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||||
if (!_showHidden) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHidden = !_showHidden;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenPhotos,
|
_prefHiddenFilterPhotos,
|
||||||
(p, key) => p.setBool(key, _showHidden),
|
(p, key) => p.setString(key, filter.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// session-restore that failed, e.g. transient network trouble at cold
|
/// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,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
|
||||||
@@ -38,6 +62,9 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
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 _prefFrostedBlur = 'ui_bottom_bar_frosted_blur';
|
||||||
|
static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity';
|
||||||
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';
|
||||||
@@ -51,6 +78,9 @@ 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;
|
||||||
|
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;
|
||||||
@@ -95,6 +125,15 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||||
|
double get bottomBarFrostedBlur => _frostedBlur;
|
||||||
|
double get bottomBarFrostedOpacity => _frostedOpacity;
|
||||||
|
|
||||||
|
/// The preset the current blur/opacity match exactly, or null when the
|
||||||
|
/// sliders have been moved to something custom.
|
||||||
|
FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values
|
||||||
|
.where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity)
|
||||||
|
.firstOrNull;
|
||||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
bool get searchInBottomBar => _searchInBottomBar;
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
@@ -155,6 +194,17 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _navMenuStyle,
|
orElse: () => _navMenuStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_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 =
|
||||||
@@ -313,6 +363,34 @@ 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) {
|
||||||
|
if (_bottomBarFrosted == value) return;
|
||||||
|
_bottomBarFrosted = value;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
|
||||||
|
}
|
||||||
|
|
||||||
void setNavMenuStyle(NooNavMenuStyle style) {
|
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||||
if (_navMenuStyle == style) return;
|
if (_navMenuStyle == style) return;
|
||||||
_navMenuStyle = style;
|
_navMenuStyle = style;
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -110,7 +111,7 @@ class SyncService {
|
|||||||
await cancelAccount(account.id);
|
await cancelAccount(account.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _channel.invokeMethod('reschedule', {
|
await invokeIfAvailable(_channel, 'reschedule', {
|
||||||
'accountId': account.id,
|
'accountId': account.id,
|
||||||
'serverUrl': account.serverUrl,
|
'serverUrl': account.serverUrl,
|
||||||
'username': account.username,
|
'username': account.username,
|
||||||
@@ -132,7 +133,7 @@ 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 {
|
static Future<void> cancelAccount(String accountId) async {
|
||||||
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
@@ -158,7 +159,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 +179,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 +198,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 +214,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 +237,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 +263,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>),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
|||||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||||
|
///
|
||||||
|
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||||
|
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||||
|
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||||
|
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||||
|
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||||
|
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||||
|
/// off the page.
|
||||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
final dark = cs.brightness == Brightness.dark;
|
||||||
|
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||||
return NooColors(
|
return NooColors(
|
||||||
bg: cs.surface,
|
bg: cs.surface,
|
||||||
surface: cs.surfaceContainerLow,
|
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||||
surface2: cs.surfaceContainerHigh,
|
surface2: cs.surfaceContainerHigh,
|
||||||
surface3: cs.surfaceContainerHighest,
|
surface3: cs.surfaceContainerHighest,
|
||||||
line: cs.outlineVariant,
|
line: cs.outlineVariant,
|
||||||
|
|||||||
@@ -76,6 +76,57 @@ class FilesView extends StatefulWidget {
|
|||||||
State<FilesView> createState() => _FilesViewState();
|
State<FilesView> createState() => _FilesViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The collapsible "External storage" header [StorageScope.all] puts above
|
||||||
|
/// the external items.
|
||||||
|
class _ExternalStorageHeader extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ExternalStorageHeader({
|
||||||
|
required this.count,
|
||||||
|
required this.expanded,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
expanded: expanded,
|
||||||
|
label: 'External storage',
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'External storage',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$count', style: NooText.body.copyWith(color: colors.fg3)),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(
|
||||||
|
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||||
|
size: 18,
|
||||||
|
color: colors.fg3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||||
/// whenever the folder changes (folder path + item id) so Flutter discards
|
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||||
/// and remounts the Element instead of just updating it in place - that's
|
/// and remounts the Element instead of just updating it in place - that's
|
||||||
@@ -236,6 +287,9 @@ class _GridThumbnail extends StatelessWidget {
|
|||||||
|
|
||||||
class _FilesViewState extends State<FilesView> {
|
class _FilesViewState extends State<FilesView> {
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||||
|
bool _externalExpanded = true;
|
||||||
int _lastPathDepth = 1;
|
int _lastPathDepth = 1;
|
||||||
|
|
||||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||||
@@ -245,9 +299,19 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
/// The folder data source for this tab. Watches the concrete controller
|
/// The folder data source for this tab. Watches the concrete controller
|
||||||
/// (that's what's registered as a provider), then hands it back as the
|
/// (that's what's registered as a provider), then hands it back as the
|
||||||
/// [FolderBrowser] the rest of the view actually needs.
|
/// [FolderBrowser] the rest of the view actually needs.
|
||||||
FolderBrowser _browserOf(BuildContext context) => _offline
|
///
|
||||||
|
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||||
|
/// of `build` asserts in debug builds (silently swallowing the tap).
|
||||||
|
FolderBrowser _browserOf(BuildContext context, {bool listen = true}) {
|
||||||
|
if (listen) {
|
||||||
|
return _offline
|
||||||
? context.watch<OfflineController>()
|
? context.watch<OfflineController>()
|
||||||
: context.watch<FilesController>();
|
: context.watch<FilesController>();
|
||||||
|
}
|
||||||
|
return _offline
|
||||||
|
? context.read<OfflineController>()
|
||||||
|
: context.read<FilesController>();
|
||||||
|
}
|
||||||
|
|
||||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
/// The on-device copy of [item] for the Offline tab; null online, where
|
||||||
/// thumbnails come from server previews instead.
|
/// thumbnails come from server previews instead.
|
||||||
@@ -287,7 +351,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
NextcloudItem item, {
|
NextcloudItem item, {
|
||||||
required bool picking,
|
required bool picking,
|
||||||
}) {
|
}) {
|
||||||
final browser = _browserOf(context);
|
final browser = _browserOf(context, listen: false);
|
||||||
if (picking) {
|
if (picking) {
|
||||||
_handlePickTap(context, item);
|
_handlePickTap(context, item);
|
||||||
} else if (_isSelecting) {
|
} else if (_isSelecting) {
|
||||||
@@ -659,6 +723,131 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// One sliver group per layout (grid / desktop table / mobile list) for
|
||||||
|
// an arbitrary slice of items - [StorageScope.all] renders two of them,
|
||||||
|
// the regular items and then the external ones under a collapsible
|
||||||
|
// header, instead of one list over `browser.items`.
|
||||||
|
List<Widget> itemSlivers(
|
||||||
|
List<NextcloudItem> items, {
|
||||||
|
required String section,
|
||||||
|
bool tableHeader = true,
|
||||||
|
}) {
|
||||||
|
if (files.isGridView) {
|
||||||
|
return [
|
||||||
|
SliverPadding(
|
||||||
|
key: ValueKey('files-grid-$section'),
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
gutter,
|
||||||
|
NooSpace.xs,
|
||||||
|
gutter,
|
||||||
|
NooSpace.lg,
|
||||||
|
),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: isDesktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
|
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||||
|
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||||
|
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _FolderEnterAnimation(
|
||||||
|
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||||
|
index: index,
|
||||||
|
fromRight: navigatingDeeper,
|
||||||
|
child: _buildGridCard(context, item),
|
||||||
|
);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isDesktop) {
|
||||||
|
return [
|
||||||
|
if (tableHeader)
|
||||||
|
SliverPadding(
|
||||||
|
key: const ValueKey('files-table-header'),
|
||||||
|
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: _buildDesktopHeader(
|
||||||
|
context,
|
||||||
|
files,
|
||||||
|
browser.currentFolderPath,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
key: ValueKey('files-table-$section'),
|
||||||
|
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _FolderEnterAnimation(
|
||||||
|
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||||
|
index: index,
|
||||||
|
fromRight: navigatingDeeper,
|
||||||
|
child: _buildDesktopRow(context, item),
|
||||||
|
);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
SliverPadding(
|
||||||
|
key: ValueKey('files-list-$section'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xs,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.lg,
|
||||||
|
),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _FolderEnterAnimation(
|
||||||
|
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||||
|
index: index,
|
||||||
|
fromRight: navigatingDeeper,
|
||||||
|
child: _buildMobileRow(context, item, index, items.length),
|
||||||
|
);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "All + external": external items leave the main list for their own
|
||||||
|
// collapsible section below it. Offline has no such distinction.
|
||||||
|
final splitExternal = !_offline && files.storageScope == StorageScope.all;
|
||||||
|
final mainItems = splitExternal
|
||||||
|
? browser.items.where((i) => !i.isExternalStorage).toList()
|
||||||
|
: browser.items;
|
||||||
|
final externalItems = splitExternal
|
||||||
|
? browser.items.where((i) => i.isExternalStorage).toList()
|
||||||
|
: const <NextcloudItem>[];
|
||||||
|
final bodySlivers = <Widget>[
|
||||||
|
if (mainItems.isNotEmpty) ...itemSlivers(mainItems, section: 'main'),
|
||||||
|
if (externalItems.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _ExternalStorageHeader(
|
||||||
|
count: externalItems.length,
|
||||||
|
expanded: _externalExpanded,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _externalExpanded = !_externalExpanded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_externalExpanded)
|
||||||
|
...itemSlivers(
|
||||||
|
externalItems,
|
||||||
|
section: 'external',
|
||||||
|
tableHeader: mainItems.isEmpty,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Pinned in both states - while browsing this is the controls row
|
// Pinned in both states - while browsing this is the controls row
|
||||||
@@ -743,94 +932,17 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (files.isGridView)
|
else
|
||||||
SliverPadding(
|
...bodySlivers,
|
||||||
key: const ValueKey('files-grid'),
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
gutter,
|
|
||||||
NooSpace.xs,
|
|
||||||
gutter,
|
|
||||||
NooSpace.lg,
|
|
||||||
),
|
|
||||||
sliver: SliverGrid(
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: isDesktop
|
|
||||||
? 5
|
|
||||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
|
||||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
|
||||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
|
||||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = browser.items[index];
|
|
||||||
return _FolderEnterAnimation(
|
|
||||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
|
||||||
index: index,
|
|
||||||
fromRight: navigatingDeeper,
|
|
||||||
child: _buildGridCard(context, item),
|
|
||||||
);
|
|
||||||
}, childCount: browser.items.length),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (isDesktop) ...[
|
|
||||||
SliverPadding(
|
|
||||||
key: const ValueKey('files-table-header'),
|
|
||||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
|
||||||
sliver: SliverToBoxAdapter(
|
|
||||||
child: _buildDesktopHeader(
|
|
||||||
context,
|
|
||||||
files,
|
|
||||||
browser.currentFolderPath,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverPadding(
|
|
||||||
key: const ValueKey('files-table'),
|
|
||||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = browser.items[index];
|
|
||||||
return _FolderEnterAnimation(
|
|
||||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
|
||||||
index: index,
|
|
||||||
fromRight: navigatingDeeper,
|
|
||||||
child: _buildDesktopRow(context, item),
|
|
||||||
);
|
|
||||||
}, childCount: browser.items.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
SliverPadding(
|
|
||||||
key: const ValueKey('files-list'),
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
NooSpace.sm,
|
|
||||||
NooSpace.xs,
|
|
||||||
NooSpace.sm,
|
|
||||||
NooSpace.lg,
|
|
||||||
),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = browser.items[index];
|
|
||||||
return _FolderEnterAnimation(
|
|
||||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
|
||||||
index: index,
|
|
||||||
fromRight: navigatingDeeper,
|
|
||||||
child: _buildMobileRow(
|
|
||||||
context,
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
browser.items.length,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, childCount: browser.items.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||||
// once Scaffold shrinks the body above an attached bar, but floating
|
// once Scaffold shrinks the body above an attached bar, but floating
|
||||||
// draws the body behind the bar instead, so it needs the bar's own
|
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||||
// footprint added on top or the last row ends up under it.
|
// needs the bar's own footprint added on top or the last row ends up
|
||||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
// under it.
|
||||||
NooBottomBarStyle.floating)
|
if (NooBottomBar.drawsBehindBody(
|
||||||
|
context.watch<SettingsController>().bottomBarStyle,
|
||||||
|
context.watch<SettingsController>().bottomBarFrosted,
|
||||||
|
))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: bottomBarClearance(context)),
|
child: SizedBox(height: bottomBarClearance(context)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../providers/settings_controller.dart';
|
|||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
|
import '../widgets/filter_mode_rows.dart';
|
||||||
import '../widgets/noo/core/noo_button.dart';
|
import '../widgets/noo/core/noo_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';
|
||||||
@@ -404,7 +405,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
final filtersActive =
|
final filtersActive =
|
||||||
_typeFilter != _PhotoTypeFilter.all ||
|
_typeFilter != _PhotoTypeFilter.all ||
|
||||||
photos.showFavoritesOnly ||
|
photos.showFavoritesOnly ||
|
||||||
photos.showHidden ||
|
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||||
files.storageScope != StorageScope.cloud;
|
files.storageScope != StorageScope.cloud;
|
||||||
|
|
||||||
// A plain Row, not a horizontally-scrolling one - see
|
// A plain Row, not a horizontally-scrolling one - see
|
||||||
@@ -521,7 +522,10 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Files' own type filter - not a fully-labelled track, so
|
// Files' own type filter - not a fully-labelled track, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<_PhotoTypeFilter>(
|
FilterSection(
|
||||||
|
icon: LucideIcons.layoutGrid,
|
||||||
|
title: 'Type',
|
||||||
|
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
fill: true,
|
fill: true,
|
||||||
onSurface: true,
|
onSurface: true,
|
||||||
labelOnlySelected: true,
|
labelOnlySelected: true,
|
||||||
@@ -548,6 +552,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
NooGroupedList(
|
||||||
children: [
|
children: [
|
||||||
@@ -559,26 +564,18 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
onChanged: (_) => photos.toggleFavoritesFilter(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.eye,
|
|
||||||
label: const Text('Show hidden files'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: photos.showHidden,
|
|
||||||
onChanged: (_) => photos.toggleShowHidden(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.hardDrive,
|
|
||||||
label: const Text('External storage'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: files.storageScope == StorageScope.external,
|
|
||||||
onChanged: (external) => files.setStorageScope(
|
|
||||||
external ? StorageScope.external : StorageScope.cloud,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
HiddenFilesFilterRow(
|
||||||
|
value: photos.hiddenFilter,
|
||||||
|
onChanged: photos.setHiddenFilter,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
StorageScopeRow(
|
||||||
|
value: files.storageScope,
|
||||||
|
onChanged: files.setStorageScope,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -332,7 +332,6 @@ 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: 40),
|
||||||
const SizedBox(width: 44),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import 'noo/nav/noo_bottom_bar.dart';
|
|||||||
class BottomNavBar extends StatelessWidget {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
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;
|
||||||
@@ -25,6 +28,9 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
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,
|
||||||
@@ -36,6 +42,9 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
return NooBottomBar(
|
return NooBottomBar(
|
||||||
style: style,
|
style: style,
|
||||||
barStyle: barStyle,
|
barStyle: barStyle,
|
||||||
|
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),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
|||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'noo/core/noo_chip.dart';
|
import 'noo/core/noo_chip.dart';
|
||||||
import 'noo/core/noo_segmented_control.dart';
|
import 'noo/core/noo_segmented_control.dart';
|
||||||
import 'noo/core/noo_toggle.dart';
|
import 'filter_mode_rows.dart';
|
||||||
import 'noo/lists/noo_grouped_list.dart';
|
import 'noo/lists/noo_grouped_list.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'noo/overlays/noo_sheet.dart';
|
import 'noo/overlays/noo_sheet.dart';
|
||||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final filtersActive =
|
final filtersActive =
|
||||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||||
files.showHiddenFiles ||
|
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -171,7 +171,10 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Photos' own type filter below - not a checkmark list, so
|
// Photos' own type filter below - not a checkmark list, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<FilesTypeFilter>(
|
FilterSection(
|
||||||
|
icon: LucideIcons.layoutGrid,
|
||||||
|
title: 'Type',
|
||||||
|
child: NooSegmentedControl<FilesTypeFilter>(
|
||||||
fill: true,
|
fill: true,
|
||||||
onSurface: true,
|
onSurface: true,
|
||||||
labelOnlySelected: true,
|
labelOnlySelected: true,
|
||||||
@@ -195,30 +198,19 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
HiddenFilesFilterRow(
|
||||||
children: [
|
value: files.hiddenFilter,
|
||||||
NooSettingsRow(
|
onChanged: files.setHiddenFilter,
|
||||||
icon: LucideIcons.eye,
|
|
||||||
label: const Text('Show hidden files'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: files.showHiddenFiles,
|
|
||||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showStorageScope)
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.hardDrive,
|
|
||||||
label: const Text('External storage'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: files.storageScope == StorageScope.external,
|
|
||||||
onChanged: (external) => files.setStorageScope(
|
|
||||||
external ? StorageScope.external : StorageScope.cloud,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
if (showStorageScope) ...[
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
StorageScopeRow(
|
||||||
|
value: files.storageScope,
|
||||||
|
onChanged: files.setStorageScope,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import 'noo/core/noo_segmented_control.dart';
|
||||||
|
|
||||||
|
/// "Hidden files" row of the Files/Photos filter sheets: hide them (the
|
||||||
|
/// default), show only them, or show them alongside everything else. Same
|
||||||
|
/// icon-always/label-when-selected pill as the type filter above it in
|
||||||
|
/// both sheets.
|
||||||
|
class HiddenFilesFilterRow extends StatelessWidget {
|
||||||
|
final HiddenFilesFilter value;
|
||||||
|
final ValueChanged<HiddenFilesFilter> onChanged;
|
||||||
|
|
||||||
|
const HiddenFilesFilterRow({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _FilterModeRow<HiddenFilesFilter>(
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
title: 'Hidden files',
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.hide,
|
||||||
|
icon: LucideIcons.eyeOff,
|
||||||
|
label: 'Hide',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.only,
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
label: 'Only hidden',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.include,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "External storage" row of the Files/Photos filter sheets: cloud only
|
||||||
|
/// (the default), only external storage, or both. Files shows the external
|
||||||
|
/// items in a collapsible section for [StorageScope.all].
|
||||||
|
class StorageScopeRow extends StatelessWidget {
|
||||||
|
final StorageScope value;
|
||||||
|
final ValueChanged<StorageScope> onChanged;
|
||||||
|
|
||||||
|
const StorageScopeRow({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _FilterModeRow<StorageScope>(
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
title: 'External storage',
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.cloud,
|
||||||
|
icon: LucideIcons.cloud,
|
||||||
|
label: 'Cloud only',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.external,
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
label: 'Only external',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.all,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An icon + title line above [child] - the shape every row of the Files/
|
||||||
|
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||||
|
/// control, which lives in its own file.
|
||||||
|
class FilterSection extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const FilterSection({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: colors.fg2),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModeRow<T> extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final T value;
|
||||||
|
final ValueChanged<T> onChanged;
|
||||||
|
final List<NooSegmentOption<T>> options;
|
||||||
|
|
||||||
|
const _FilterModeRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FilterSection(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
child: NooSegmentedControl<T>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
labelOnlySelected: true,
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
import 'noo_nav_style.dart';
|
import 'noo_nav_style.dart';
|
||||||
@@ -26,6 +27,9 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
||||||
/// rather than popping in/out on the destination item itself.
|
/// rather than popping in/out on the destination item itself.
|
||||||
///
|
///
|
||||||
|
/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android
|
||||||
|
/// row below, so iOS gets the same icon-only idle tabs.
|
||||||
|
///
|
||||||
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||||
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||||
@@ -45,6 +49,13 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// body draws behind it - see `tab_state_slivers.dart`'s
|
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||||
/// `bottomBarClearance`.
|
/// `bottomBarClearance`.
|
||||||
///
|
///
|
||||||
|
/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a
|
||||||
|
/// translucent one over a backdrop blur, on both bar styles and both nav
|
||||||
|
/// styles, and drops [nooDialogShadow] (a shadow would show through the
|
||||||
|
/// glass). It only reads as glass if content draws behind the bar, so the
|
||||||
|
/// host `Scaffold` needs `extendBody: true` and each scrollable body needs
|
||||||
|
/// the same trailing clearance floating does - see [drawsBehindBody].
|
||||||
|
///
|
||||||
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||||
@@ -54,6 +65,13 @@ const double _kAndroidPillHeight = 32;
|
|||||||
class NooBottomBar extends StatelessWidget {
|
class NooBottomBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
|
|
||||||
|
/// Backdrop blur sigma and surface-fill opacity while [frosted] (user-
|
||||||
|
/// adjustable in Settings; the defaults match [FrostedGlassContainer]'s
|
||||||
|
/// blur).
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
final List<NooNavDestination> destinations;
|
final List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
@@ -64,6 +82,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
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,
|
||||||
@@ -75,10 +96,21 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||||
/// much clearance (see the class doc comment) instead of guessing.
|
/// much clearance (see the class doc comment) instead of guessing.
|
||||||
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
||||||
if (style == NooNavStyle.ios) return 50;
|
if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const double defaultFrostedBlur = 20;
|
||||||
|
static const double defaultFrostedOpacity = 0.72;
|
||||||
|
|
||||||
|
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||||
|
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||||
|
/// frosted bar (there's nothing to blur otherwise).
|
||||||
|
static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) =>
|
||||||
|
frosted || barStyle == NooBottomBarStyle.floating;
|
||||||
|
|
||||||
/// Gap between the floating bar's bottom edge and the safe area below it
|
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||||
static const double floatingBottomMargin = 12;
|
static const double floatingBottomMargin = 12;
|
||||||
@@ -86,8 +118,10 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final ios = style == NooNavStyle.ios;
|
|
||||||
final floating = barStyle == NooBottomBarStyle.floating;
|
final floating = barStyle == NooBottomBarStyle.floating;
|
||||||
|
// Floating always gets the Android row (icon-only idle tabs, sliding
|
||||||
|
// pill) - iOS's icon-over-label row doesn't fit a pill that short.
|
||||||
|
final ios = style == NooNavStyle.ios && !floating;
|
||||||
final hasSearch = searchDestination != null && onSearchTap != null;
|
final hasSearch = searchDestination != null && onSearchTap != null;
|
||||||
// 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
|
||||||
@@ -104,17 +138,21 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||||
|
|
||||||
if (floating) {
|
if (floating) {
|
||||||
final pill = Container(
|
final pillRadius = BorderRadius.circular(28);
|
||||||
|
final pillBox = Container(
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: pillRadius,
|
||||||
boxShadow: const [nooDialogShadow],
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
|
final pill = frosted
|
||||||
|
? _blurred(pillBox, pillRadius, frostedBlur)
|
||||||
|
: pillBox;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -145,6 +183,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
destination: searchDestination!,
|
destination: searchDestination!,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
size: barHeight,
|
size: barHeight,
|
||||||
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -154,9 +195,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
final bar = Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -164,8 +205,13 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
child: SizedBox(height: barHeight, child: row),
|
child: SizedBox(height: barHeight, child: row),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _fill(NooColors colors) => frosted
|
||||||
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
|
: colors.surface;
|
||||||
|
|
||||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -248,6 +294,16 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||||
|
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
||||||
|
Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// The floating bar's own Search entry point, next to the pill rather than
|
/// The floating bar's own Search entry point, next to the pill rather than
|
||||||
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||||
/// same fill/border as the pill so the two still read as one family.
|
/// same fill/border as the pill so the two still read as one family.
|
||||||
@@ -255,11 +311,17 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final double size;
|
final double size;
|
||||||
|
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.frostedBlur,
|
||||||
|
required this.frostedOpacity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -271,20 +333,29 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: _satelliteBody(colors),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _satelliteBody(NooColors colors) {
|
||||||
|
final disc = Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: frosted
|
||||||
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
|
: colors.surface,
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: const [nooDialogShadow],
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
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), frostedBlur)
|
||||||
|
: disc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -53,6 +54,18 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeRow(settings: settings),
|
_ThemeRow(settings: settings),
|
||||||
_BottomBarStyleRow(settings: settings),
|
_BottomBarStyleRow(settings: settings),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.glassWater,
|
||||||
|
label: const Text('Frosted glass bottom bar'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'A translucent, blurred bottom bar - content scrolls behind it',
|
||||||
|
),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: settings.bottomBarFrosted,
|
||||||
|
onChanged: settings.setBottomBarFrosted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||||
_NavMenuStyleRow(settings: settings),
|
_NavMenuStyleRow(settings: settings),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
@@ -240,6 +253,117 @@ 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,
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
|||||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||||
/// Scaffold already shrinks the body above them - this is then pure
|
/// Scaffold already shrinks the body above them - this is then pure
|
||||||
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
||||||
/// bars draw over an extended body instead, so nothing reserves space for
|
/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an
|
||||||
|
/// extended body instead, so nothing reserves space for
|
||||||
/// them automatically: the clearance has to cover the bar's own footprint
|
/// them automatically: the clearance has to cover the bar's own footprint
|
||||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||||
double bottomBarClearance(BuildContext context) {
|
double bottomBarClearance(BuildContext context) {
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
final barStyle = settings.bottomBarStyle;
|
||||||
|
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
final barHeight = NooBottomBar.rowHeight(
|
final barHeight = NooBottomBar.rowHeight(
|
||||||
NooLayout.navStyle(context),
|
NooLayout.navStyle(context),
|
||||||
NooBottomBarStyle.floating,
|
barStyle,
|
||||||
);
|
);
|
||||||
|
final margin = barStyle == NooBottomBarStyle.floating
|
||||||
|
? NooBottomBar.floatingBottomMargin
|
||||||
|
: 0;
|
||||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||||
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
return barHeight + margin + safeBottom + 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/models/nextcloud_item.dart';
|
||||||
|
import 'package:noo/providers/connectivity_controller.dart';
|
||||||
|
import 'package:noo/providers/files_controller.dart';
|
||||||
|
import 'package:noo/providers/session_controller.dart';
|
||||||
|
|
||||||
|
NextcloudItem _item(String path, {String? mountType}) => NextcloudItem(
|
||||||
|
id: path,
|
||||||
|
name: path.split('/').last,
|
||||||
|
path: path,
|
||||||
|
type: NextcloudItemType.file,
|
||||||
|
size: 1,
|
||||||
|
lastModified: DateTime(2026),
|
||||||
|
mountType: mountType,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and
|
||||||
|
/// how a pref saved before the three-way hidden filter is read back.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||||
|
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||||
|
);
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||||
|
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||||
|
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
final items = [
|
||||||
|
_item('/a.txt'),
|
||||||
|
_item('/.secret'),
|
||||||
|
_item('/docs/.git/config'),
|
||||||
|
_item('/nas', mountType: 'external'),
|
||||||
|
_item('/.nas-hidden', mountType: 'external'),
|
||||||
|
];
|
||||||
|
List<String> paths(List<NextcloudItem> l) => l.map((i) => i.path).toList();
|
||||||
|
|
||||||
|
FilesController build() =>
|
||||||
|
FilesController(SessionController(ConnectivityController()));
|
||||||
|
|
||||||
|
test('hidden filter: hide / only / include', () {
|
||||||
|
final files = build()..setStorageScope(StorageScope.all);
|
||||||
|
List<String> run(HiddenFilesFilter h) => paths(
|
||||||
|
files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h),
|
||||||
|
);
|
||||||
|
expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']);
|
||||||
|
expect(run(HiddenFilesFilter.only), [
|
||||||
|
'/.secret',
|
||||||
|
'/docs/.git/config',
|
||||||
|
'/.nas-hidden',
|
||||||
|
]);
|
||||||
|
expect(run(HiddenFilesFilter.include), paths(items));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage scope: cloud / external / all', () {
|
||||||
|
final files = build();
|
||||||
|
List<String> run(StorageScope s) {
|
||||||
|
files.setStorageScope(s);
|
||||||
|
return paths(
|
||||||
|
files.applyCommonFilters(
|
||||||
|
items,
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
hidden: HiddenFilesFilter.include,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(run(StorageScope.cloud), [
|
||||||
|
'/a.txt',
|
||||||
|
'/.secret',
|
||||||
|
'/docs/.git/config',
|
||||||
|
]);
|
||||||
|
expect(run(StorageScope.external), ['/nas', '/.nas-hidden']);
|
||||||
|
expect(run(StorageScope.all), paths(items));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden + external combine', () {
|
||||||
|
final files = build()..setStorageScope(StorageScope.external);
|
||||||
|
expect(
|
||||||
|
paths(
|
||||||
|
files.applyCommonFilters(
|
||||||
|
items,
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
hidden: HiddenFilesFilter.only,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
['/.nas-hidden'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('loadHiddenFilter', () {
|
||||||
|
Future<HiddenFilesFilter> load(Map<String, Object> values) async {
|
||||||
|
SharedPreferences.setMockInitialValues(values);
|
||||||
|
return FilesController.loadHiddenFilter(
|
||||||
|
await SharedPreferences.getInstance(),
|
||||||
|
'new',
|
||||||
|
'old',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('defaults to hide', () async {
|
||||||
|
expect(await load({}), HiddenFilesFilter.hide);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the old show-hidden bool maps to hide / include', () async {
|
||||||
|
expect(await load({'old': true}), HiddenFilesFilter.include);
|
||||||
|
expect(await load({'old': false}), HiddenFilesFilter.hide);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the new key wins and an unknown value falls back to hide', () async {
|
||||||
|
expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only);
|
||||||
|
expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/providers/connectivity_controller.dart';
|
||||||
|
import 'package:noo/providers/session_controller.dart';
|
||||||
|
import 'package:noo/services/app_lock_service.dart';
|
||||||
|
|
||||||
|
/// The three Settings -> Security locks (open the app, switch accounts, reveal
|
||||||
|
/// hidden files) each work on their own - none needs or implies another. The
|
||||||
|
/// device prompt is replaced with a recorder so the gates can be exercised.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||||
|
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||||
|
);
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||||
|
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||||
|
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
late List<String> prompts;
|
||||||
|
var authSucceeds = true;
|
||||||
|
var deviceSupported = true;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
prompts = [];
|
||||||
|
authSucceeds = true;
|
||||||
|
deviceSupported = true;
|
||||||
|
AppLockService.debugAuthenticate = (reason) async {
|
||||||
|
prompts.add(reason);
|
||||||
|
return authSucceeds;
|
||||||
|
};
|
||||||
|
AppLockService.debugIsDeviceSupported = () async => deviceSupported;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
AppLockService.debugAuthenticate = null;
|
||||||
|
AppLockService.debugIsDeviceSupported = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
SessionController build() => SessionController(ConnectivityController());
|
||||||
|
|
||||||
|
test(
|
||||||
|
'hidden files and account switching can be locked without login lock',
|
||||||
|
() async {
|
||||||
|
final session = build();
|
||||||
|
|
||||||
|
expect(await session.setLockHiddenFiles(true), isTrue);
|
||||||
|
expect(await session.setLockAccountSwitching(true), isTrue);
|
||||||
|
|
||||||
|
expect(session.lockHiddenFiles, isTrue);
|
||||||
|
expect(session.lockAccountSwitching, isTrue);
|
||||||
|
expect(session.loginLockEnabled, isFalse);
|
||||||
|
expect(
|
||||||
|
session.needsUnlock,
|
||||||
|
isFalse,
|
||||||
|
reason: 'opening the app stays open',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a gate prompts on its own, with login lock off', () async {
|
||||||
|
final session = build();
|
||||||
|
|
||||||
|
expect(await session.passGate(false, 'unused'), isTrue);
|
||||||
|
expect(prompts, isEmpty, reason: 'a gate that is off never prompts');
|
||||||
|
|
||||||
|
expect(await session.passGate(true, 'Unlock to show hidden files'), isTrue);
|
||||||
|
expect(prompts, ['Unlock to show hidden files']);
|
||||||
|
|
||||||
|
authSucceeds = false;
|
||||||
|
expect(await session.passGate(true, 'again'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'turning a lock on needs a capable device and a successful auth',
|
||||||
|
() async {
|
||||||
|
final session = build();
|
||||||
|
|
||||||
|
deviceSupported = false;
|
||||||
|
expect(await session.setLockHiddenFiles(true), isFalse);
|
||||||
|
expect(session.lockHiddenFiles, isFalse);
|
||||||
|
|
||||||
|
deviceSupported = true;
|
||||||
|
authSucceeds = false;
|
||||||
|
expect(await session.setLockHiddenFiles(true), isFalse);
|
||||||
|
expect(session.lockHiddenFiles, isFalse);
|
||||||
|
|
||||||
|
authSucceeds = true;
|
||||||
|
expect(await session.setLockHiddenFiles(true), isTrue);
|
||||||
|
expect(session.lockHiddenFiles, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'turning a lock off needs auth, so it cannot just be flipped away',
|
||||||
|
() async {
|
||||||
|
final session = build();
|
||||||
|
await session.setLockAccountSwitching(true);
|
||||||
|
prompts.clear();
|
||||||
|
|
||||||
|
authSucceeds = false;
|
||||||
|
expect(await session.setLockAccountSwitching(false), isFalse);
|
||||||
|
expect(session.lockAccountSwitching, isTrue);
|
||||||
|
|
||||||
|
authSucceeds = true;
|
||||||
|
expect(await session.setLockAccountSwitching(false), isTrue);
|
||||||
|
expect(session.lockAccountSwitching, isFalse);
|
||||||
|
expect(prompts, hasLength(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('setting a lock to its current value does not prompt', () async {
|
||||||
|
final session = build();
|
||||||
|
expect(await session.setLockHiddenFiles(false), isTrue);
|
||||||
|
expect(prompts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turning login lock off leaves the other two locks alone', () async {
|
||||||
|
final session = build();
|
||||||
|
await session.setupLoginLock();
|
||||||
|
await session.setLockAccountSwitching(true);
|
||||||
|
await session.setLockHiddenFiles(true);
|
||||||
|
expect(session.loginLockEnabled, isTrue);
|
||||||
|
|
||||||
|
expect(await session.disableLoginLock(), isTrue);
|
||||||
|
|
||||||
|
expect(session.loginLockEnabled, isFalse);
|
||||||
|
expect(session.lockAccountSwitching, isTrue);
|
||||||
|
expect(session.lockHiddenFiles, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each lock is saved on its own and restored', () async {
|
||||||
|
final first = build();
|
||||||
|
await first.setLockHiddenFiles(true);
|
||||||
|
// Let the fire-and-forget pref write land.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final second = build();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
expect(second.lockHiddenFiles, isTrue);
|
||||||
|
expect(second.lockAccountSwitching, isFalse);
|
||||||
|
expect(second.loginLockEnabled, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
|
||||||
|
/// The frosted-glass bottom bar's blur/opacity settings and their presets.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
Future<SettingsController> build() async {
|
||||||
|
final settings = SettingsController();
|
||||||
|
// `_load` is async and not exposed; one event-loop turn lets it finish.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('starts on the Default preset', () async {
|
||||||
|
final settings = await build();
|
||||||
|
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||||
|
expect(settings.bottomBarFrostedBlur, 20);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, 0.72);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets set both values and are recognised again', () async {
|
||||||
|
final settings = await build();
|
||||||
|
for (final preset in FrostedGlassPreset.values) {
|
||||||
|
settings.setFrostedPreset(preset);
|
||||||
|
expect(settings.bottomBarFrostedBlur, preset.blur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, preset.opacity);
|
||||||
|
expect(settings.frostedPreset, preset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets run from lighter to stronger frosting', () {
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.less.blur,
|
||||||
|
lessThan(FrostedGlassPreset.standard.blur),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.standard.blur,
|
||||||
|
lessThan(FrostedGlassPreset.more.blur),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.less.opacity,
|
||||||
|
greaterThan(FrostedGlassPreset.standard.opacity),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.standard.opacity,
|
||||||
|
greaterThan(FrostedGlassPreset.more.opacity),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving a slider off every preset leaves none selected', () async {
|
||||||
|
final settings = await build();
|
||||||
|
settings.setFrostedBlur(27);
|
||||||
|
expect(settings.frostedPreset, isNull);
|
||||||
|
settings.setFrostedBlur(FrostedGlassPreset.standard.blur);
|
||||||
|
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('values are clamped to their ranges', () async {
|
||||||
|
final settings = await build();
|
||||||
|
settings
|
||||||
|
..setFrostedBlur(500)
|
||||||
|
..setFrostedOpacity(0);
|
||||||
|
expect(settings.bottomBarFrostedBlur, maxFrostedBlur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, minFrostedOpacity);
|
||||||
|
settings
|
||||||
|
..setFrostedBlur(-3)
|
||||||
|
..setFrostedOpacity(9);
|
||||||
|
expect(settings.bottomBarFrostedBlur, minFrostedBlur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved values are restored (and clamped) on the next launch', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ui_bottom_bar_frosted_blur': 32.0,
|
||||||
|
'ui_bottom_bar_frosted_opacity': 7.0,
|
||||||
|
});
|
||||||
|
final settings = await build();
|
||||||
|
expect(settings.bottomBarFrostedBlur, 32);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ 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/app_top_bar.dart';
|
import 'package:noo/widgets/app_top_bar.dart';
|
||||||
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
|
||||||
@@ -174,4 +176,48 @@ 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:noo/providers/files_controller.dart';
|
||||||
|
import 'package:noo/widgets/filter_mode_rows.dart';
|
||||||
|
|
||||||
|
import 'noo/noo_test_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpNooTests();
|
||||||
|
|
||||||
|
group('HiddenFilesFilterRow', () {
|
||||||
|
testNooWidgets(
|
||||||
|
'only the selected option shows its label, tapping another reports it',
|
||||||
|
(tester, theme, c) async {
|
||||||
|
var value = HiddenFilesFilter.hide;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
StatefulBuilder(
|
||||||
|
builder: (context, setState) => SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: HiddenFilesFilterRow(
|
||||||
|
value: value,
|
||||||
|
onChanged: (v) => setState(() => value = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(find.text('Hidden files'), findsOneWidget);
|
||||||
|
expect(find.text('Hide'), findsOneWidget);
|
||||||
|
expect(find.text('Only hidden'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value, HiddenFilesFilter.include);
|
||||||
|
expect(find.text('All'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('StorageScopeRow', () {
|
||||||
|
testNooWidgets('offers cloud only / only external / all', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var value = StorageScope.cloud;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
StatefulBuilder(
|
||||||
|
builder: (context, setState) => SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: StorageScopeRow(
|
||||||
|
value: value,
|
||||||
|
onChanged: (v) => setState(() => value = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(find.text('External storage'), findsOneWidget);
|
||||||
|
expect(find.text('Cloud only'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.hardDrive).last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value, StorageScope.external);
|
||||||
|
expect(find.text('Only external'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value, StorageScope.all);
|
||||||
|
expect(find.text('All'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -74,6 +75,59 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('NooColors.fromDynamicScheme surfaces', () {
|
||||||
|
for (final brightness in Brightness.values) {
|
||||||
|
test('cards are clearly lighter than the page (${brightness.name})', () {
|
||||||
|
// The OS palette overrides `surface` (what `bg` maps to) but not the
|
||||||
|
// seeded container roles - mimic that mismatch.
|
||||||
|
final seeded = ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xFF3F6B8F),
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
final os = seeded.copyWith(
|
||||||
|
surface: brightness == Brightness.light
|
||||||
|
? const Color(0xFFF7F9FC)
|
||||||
|
: const Color(0xFF101417),
|
||||||
|
);
|
||||||
|
final c = NooColors.fromDynamicScheme(os);
|
||||||
|
// WCAG-style contrast ratio between the card and the page.
|
||||||
|
final hi = c.surface.computeLuminance();
|
||||||
|
final lo = c.bg.computeLuminance();
|
||||||
|
expect(hi, greaterThan(lo));
|
||||||
|
expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NooSlider', () {
|
||||||
|
testNooWidgets('dragging reports a value within min..max', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var value = 10.0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
StatefulBuilder(
|
||||||
|
builder: (context, setState) => SizedBox(
|
||||||
|
width: 300,
|
||||||
|
child: NooSlider(
|
||||||
|
value: value,
|
||||||
|
min: 0,
|
||||||
|
max: 40,
|
||||||
|
onChanged: (v) => setState(() => value = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
await tester.drag(find.byType(Slider), const Offset(150, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(value, greaterThan(10));
|
||||||
|
expect(value, lessThanOrEqualTo(40));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('NooAvatar', () {
|
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';
|
||||||
@@ -25,6 +26,107 @@ void main() {
|
|||||||
setUpNooTests();
|
setUpNooTests();
|
||||||
|
|
||||||
group('NooBottomBar', () {
|
group('NooBottomBar', () {
|
||||||
|
for (final style in NooNavStyle.values) {
|
||||||
|
for (final barStyle in NooBottomBarStyle.values) {
|
||||||
|
testNooWidgets(
|
||||||
|
'$style/$barStyle: frosted draws a backdrop blur, solid does not',
|
||||||
|
(tester, theme, c) async {
|
||||||
|
for (final frosted in [false, true]) {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 120,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: style,
|
||||||
|
barStyle: barStyle,
|
||||||
|
frosted: frosted,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
searchDestination: const NooNavDestination(
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
onSearchTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byType(BackdropFilter),
|
||||||
|
frosted ? findsWidgets : findsNothing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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', () {
|
||||||
|
expect(
|
||||||
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('floating row height is the Android one on both nav styles', () {
|
||||||
|
expect(
|
||||||
|
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating),
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached),
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testNooWidgets('android: label sits below the icon, not beside it', (
|
testNooWidgets('android: label sits below the icon, not beside it', (
|
||||||
tester,
|
tester,
|
||||||
theme,
|
theme,
|
||||||
|
|||||||