Author SHA1 Message Date
Ayushya AmitabhandClaude Sonnet 5.5 4ab6c5d85c Share sheet: ask to unlock on every gated action, like the app
The sheet remembered one unlock for its whole lifetime, so later account picks
and hidden toggles skipped Face ID. Each switch / each turning-on of hidden
folders now prompts again; one action needing both is a single prompt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:24:20 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 02d9d799a6 iOS: authenticate each request by its own header only (no cookies/credential cache)
Two accounts can share one server; the Share Extension's folder listing and
the upload sessions now ignore cookies and stored credentials so one account's
session can never answer for another. Logs each PROPFIND (user, path, status).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:18:20 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 02d612bfec Share sheet: Hidden and Storage toggles, plus diagnostics for the missing accounts
Two menu chips start at the account's app settings (hidden filter, storage
scope - now published per account), hidden-on asks for the app's unlock, and
external storage is detected via nc:mount-type incl. everything under a
mount. Adds logging for which accounts get published and why one is skipped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:15:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 22a6602f08 Security locks work independently; other-account avatars flush right in the avatar menu
Login lock, lock account switching and lock hidden files no longer depend on
each other: passGate prompts on its own flag, the two sub-locks can be set
with login lock off, and disabling login lock leaves them alone. Turning any
lock on or off asks for device auth (on also checks the device can). The iOS
Share Extension's unlock rules follow. The avatar dropdown's additional
accounts lose the 44px spacer so their avatars sit at the right edge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:07:28 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 27c8ac12f2 Share Extension: remove the legacy single-account keychain item on clear
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:59:38 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 a37524e959 Share Extension: account picker first, hidden folders follow the app's filter + unlock
The app now publishes every uploadable account, the active one, the three
lock settings and each account's own hidden-files filter (ShareAccountSync).
The sheet lists accounts first when there are several, asks for unlock to use
a non-active account (login lock + account-switching lock), and shows hidden
folders per the account's app setting behind the hidden-files unlock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:59:29 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0cda4a1241 Frosted bottom bar: adjustable blur and opacity with Less/Default/More presets
Settings > Appearance shows a preset control plus Blur and Opacity sliders
(new NooSlider) while frosted glass is on; values persist and feed
NooBottomBar.frostedBlur/frostedOpacity. Default matches the old fixed look.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:46:54 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0c063c1fb4 iOS transfers: 'Uploading...' then 'Uploaded' notification, and count share uploads
The Share Extension's session had no delegate, so a fast upload finished
unseen and the invalidated session was discarded before the app could hear of
it - no summary notification. Results are now handled by a shared delegate in
whichever process is alive, started/finished notifications replace each
other in place, and the app refreshes the folder the extension uploaded to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 afc9b90963 iOS Share Extension: pick the destination inside the share sheet
SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via
a Keychain group, upload on a background session that outlives the extension
and is finished by the app. The inbox + notification hand-off remains as the
fallback (no account / 'choose later'). Shared code moves to ios/Shared.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:36:04 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 525504bc47 iOS: Share Extension (share to Noo) via an App Group inbox
ShareExtension target copies shared files into group.dev.ayushya.noo and
leaves a manifest; the app consumes it on launch/activation through the
existing share_intent channel into the destination picker. Extension is
embedded before Flutter's script phases; versions follow FLUTTER_BUILD_*.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:23:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 8b86aa8f34 iOS: show transfer notifications as banners while the app is open
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:15:59 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 469e57cca7 iOS: native upload and download services (background URLSession)
Swift implementation of the upload_service/download_service channels:
background PUT/GET against the account's WebDAV root, one summary local
notification per batch, Downloads visible in the Files app. Pure helpers
covered by RunnerTests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:12:15 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d008185f98 iOS: degrade gracefully where the Android-only native services are missing
Share/pick/sync calls and streams no longer throw MissingPluginException at
startup; upload/download/sync-now show 'X isn't available on this platform
yet'. Offline/sync mirror dir no longer uses the Android-only external dir.
Info.plist: Face ID and photo library usage strings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:06:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 813c613249 Dynamic color: keep card surfaces lighter than the page
Build APK / build (push) Successful in 5m39s
In light mode surfaceContainerLow is slightly darker than the OS surface bg
maps to, so the selection bar and other cards blended into the page. Use
surfaceContainerLowest (light) / surfaceContainer (dark) for NooColors.surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:53:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 c0a67f3efe iOS: replace default Flutter app icons with the Noo icon
Generated from assets/icon/app_icon.png, opaque RGB at every size in the set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:42:30 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5fbac333d5 Filter sheets: 'Type' label above the type control; shorten 'All + ...' to 'All'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:38:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 3c60f8c709 Filters: three-way hidden files and external storage
Hidden: hide / only hidden / all + hidden. External: cloud only / only
external / all + external; with 'all' Files shows the external items in a
collapsible section. Old show-hidden bool prefs are still read as fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:34:11 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 944b87b054 Bottom bar: add frosted glass option (Settings > Appearance)
Translucent fill over a backdrop blur, both platforms and both bar styles.
Frosted bars draw over an extended body, so the shell's extendBody and the
tab clearance now key off NooBottomBar.drawsBehindBody.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:16:27 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d6448984d9 Files: don't context.watch from the item tap handler
watch outside build asserts in debug builds, so taps on rows silently did
nothing in debug (release strips the assert, which hid it on Android).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:13:45 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 52abd16983 Floating bottom bar: use Android row (icon-only idle tabs) on iOS too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 21:36:35 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 cb66b4a0ad iOS: raise deployment target to 15.0 (required by current Flutter)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 20:38:17 -04:00
ayushyaandClaude Code da3b8a4f05 README: explain license choice and Gitea source of truth
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:34:08 -04:00
73 changed files with 4844 additions and 323 deletions
+2 -2
View File
@@ -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
+178
View File
@@ -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.
+39 -2
View File
@@ -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
+20
View File
@@ -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.
+282 -3
View File
@@ -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 */
+30 -1
View File
@@ -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())
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+10
View File
@@ -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>
+186
View File
@@ -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
}
}
+245
View File
@@ -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
}
}
}
+14
View File
@@ -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>
+330 -3
View File
@@ -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")
}
} }
+19
View File
@@ -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)
}
}
}
}
+47
View File
@@ -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>
+406
View File
@@ -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))
}
}
+185
View File
@@ -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)
}
}
+80
View File
@@ -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 }
}
+161
View File
@@ -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 }
}
+92
View File
@@ -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 }
}
}
+80
View File
@@ -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)
}
}
}
+88
View File
@@ -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
}
}
+53
View File
@@ -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
}
}
}
+28 -4
View File
@@ -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,
+91 -25
View File
@@ -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 {
+3 -4
View File
@@ -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));
+16 -9
View File
@@ -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),
); );
} }
+46 -21
View File
@@ -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);
} }
+78
View File
@@ -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;
+10
View File
@@ -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,
+4 -1
View File
@@ -38,6 +38,9 @@ class DownloadService {
.toList(), .toList(),
); );
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson}); await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
...args,
'files': filesJson,
});
} }
} }
+55
View File
@@ -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);
}
+7 -4
View File
@@ -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');
} }
} }
+204
View File
@@ -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.
}
});
}
}
+4 -2
View File
@@ -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)
+22 -10
View File
@@ -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)
+5 -4
View File
@@ -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>),
);
} }
+11 -2
View File
@@ -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,
+200 -88
View File
@@ -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)),
), ),
+17 -20
View File
@@ -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,
),
], ],
), ),
), ),
-1
View File
@@ -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),
], ],
), ),
), ),
+9
View File
@@ -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),
+15 -23
View File
@@ -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,
), ),
], ],
),
], ],
), ),
), ),
+155
View File
@@ -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,
),
);
}
}
+51
View File
@@ -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,
),
);
}
}
+84 -13
View File
@@ -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
+35 -16
View File
@@ -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',
),
), ),
), ),
], ],
+11 -4
View File
@@ -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 -
+129
View File
@@ -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);
});
});
}
+156
View File
@@ -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);
});
}
+85
View File
@@ -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);
});
}
+67
View File
@@ -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);
});
}
+46
View File
@@ -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,
);
});
} }
+76
View File
@@ -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);
});
});
}
+54
View File
@@ -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,
+102
View File
@@ -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,