Compare commits
@@ -275,7 +275,9 @@ bare system prompt gives no context on its own.
|
|||||||
|
|
||||||
`MainShellView` builds its chrome from the Noo nav kit
|
`MainShellView` builds its chrome from the Noo nav kit
|
||||||
(`widgets/noo/nav/`) and switches between two layouts on
|
(`widgets/noo/nav/`) and switches between two layouts on
|
||||||
`NooLayout.isDesktop`:
|
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
||||||
|
phone in landscape keeps the mobile layout and only tablet-class windows get
|
||||||
|
the sidebar):
|
||||||
|
|
||||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
for *every* tab (previously only Files had shell-level top chrome, with
|
for *every* tab (previously only Files had shell-level top chrome, with
|
||||||
|
|||||||
@@ -801,3 +801,136 @@ Flutter's own default to already be high enough.
|
|||||||
`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.
|
||||||
|
|
||||||
|
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
|
||||||
|
|
||||||
|
## Changing a share's permissions
|
||||||
|
|
||||||
|
`NextcloudService.updateSharePermissions` does `PUT
|
||||||
|
/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` with a `permissions`
|
||||||
|
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||||
|
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||||
|
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||||
|
|
||||||
|
## Native services on iOS (in progress)
|
||||||
|
|
||||||
|
The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
|
||||||
|
`upload_service`, `download_service`, `sync_service`, plus their status
|
||||||
|
`EventChannel`s) were written for Android in Kotlin. iOS implements
|
||||||
|
`upload_service` (+ its status stream), `download_service` and
|
||||||
|
`share_intent` 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 signed-in account from the shared Keychain
|
||||||
|
(`SharedAccountStore`) and lists folders over WebDAV (`DavClient`
|
||||||
|
`PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden).
|
||||||
|
The Dart side keeps that account current: `ShareAccountService.publish`
|
||||||
|
(`lib/services/share_account_service.dart`) runs on every account-ready
|
||||||
|
event and `clear` on account-cleared, through the native `share_account`
|
||||||
|
channel - both wired where `SessionController` is created in `main.dart`.
|
||||||
|
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. The account 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.
|
||||||
|
|||||||
@@ -90,32 +90,6 @@ class/method already makes obvious.
|
|||||||
`Align`).
|
`Align`).
|
||||||
- Run with `flutter test`.
|
- Run with `flutter test`.
|
||||||
|
|
||||||
## Play Store screenshots
|
|
||||||
|
|
||||||
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
|
|
||||||
listing screenshots **without any real data**: it runs the real, unmodified app
|
|
||||||
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
|
|
||||||
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
|
|
||||||
it with `integration_test/store_screenshots_test.dart` (host-side driver:
|
|
||||||
`test_driver/integration_test.dart`, which saves `adb screencap` images).
|
|
||||||
Output lands in `store_listing/screenshots/` (gitignored) after
|
|
||||||
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
|
|
||||||
limits (each side 320-3840 px, long side at most 2x the short side - a stock
|
|
||||||
1080x2400 phone screen is 2.22:1 and would be rejected).
|
|
||||||
|
|
||||||
- The test logs in by writing the demo user's account/app password into
|
|
||||||
storage via `AccountStore` before calling `main()`, so it skips the browser
|
|
||||||
login flow; it also fixes the theme (light, non-dynamic colour), the visible
|
|
||||||
tabs and the first-run notification prompt so shots are reproducible.
|
|
||||||
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
|
|
||||||
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
|
|
||||||
`FilesView`s, and the fake content's names (defined in
|
|
||||||
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
|
|
||||||
- `flutter test` only runs `test/`, so this never runs as part of the normal
|
|
||||||
suite; `integration_test` is a dev-only dependency.
|
|
||||||
- Always review the images before uploading - see
|
|
||||||
`tool/demo_server/README.md` for the known places real data could appear.
|
|
||||||
|
|
||||||
## Local install/deploy
|
## Local install/deploy
|
||||||
|
|
||||||
Never use `flutter install` to push a build to a test device — it always
|
Never use `flutter install` to push a build to a test device — it always
|
||||||
@@ -157,3 +131,26 @@ Networking is deliberately split: `package:http` for simple JSON/XML
|
|||||||
request-response calls, `package:dio` only where streaming/progress is
|
request-response calls, `package:dio` only where streaming/progress is
|
||||||
needed (downloads). Don't introduce a third HTTP client — extend the
|
needed (downloads). Don't introduce a third HTTP client — extend the
|
||||||
existing split instead.
|
existing split instead.
|
||||||
|
|
||||||
|
## Play Store upload (CI)
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
|
||||||
|
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
|
||||||
|
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
|
||||||
|
`internal` track with `status: completed`, so it rolls out to internal
|
||||||
|
testers automatically (promote to other tracks in Play Console). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||||
|
repo secret (service account with release permissions; the app's first
|
||||||
|
release must be uploaded manually). Play rejects a repeated `versionCode`, so
|
||||||
|
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
|
||||||
|
will fail the upload. Release notes come from
|
||||||
|
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
|
||||||
|
|
||||||
|
## Community PR flow
|
||||||
|
|
||||||
|
Gitea is the source of truth; GitHub is a push mirror (never commit to GitHub
|
||||||
|
`main`) and the public issue tracker. `scripts/sync-github-prs.sh` (cron on the
|
||||||
|
home server) imports open GitHub PRs into Gitea via Agit
|
||||||
|
(`refs/for/main`, topic `gh-pr-N`), where `.gitea/workflows/pr.yml` runs
|
||||||
|
analyze + tests. That workflow must never use secrets; signing and Play
|
||||||
|
credentials stay on the tag-triggered `build.yml`/`release.yml`. Issue
|
||||||
|
templates are in `.github/ISSUE_TEMPLATE/`.
|
||||||
|
|||||||
@@ -29,11 +29,20 @@ 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(...)`. In tests, set
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
|
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
|
||||||
indefinitely (see `standards.md`).
|
app never contacts Google (the privacy policy on the website relies on
|
||||||
|
this — keep it true; add new weights as bundled files). Tests set the
|
||||||
|
same flag in `setUpAll` (see `standards.md`).
|
||||||
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
||||||
`surfaceContainerLow`.
|
`surfaceContainerLow`.
|
||||||
- **App bars**: flat, not centered, `surface` background.
|
- **App bars**: flat, not centered, `surface` background.
|
||||||
@@ -77,11 +86,11 @@ 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`, `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` |
|
||||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` |
|
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
|
||||||
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
||||||
|
|
||||||
Gotchas:
|
Gotchas:
|
||||||
@@ -133,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`
|
||||||
@@ -196,6 +217,27 @@ blocks are noted where they matter:
|
|||||||
bars and video transport controls (its other former user, the floating
|
bars and video transport controls (its other former user, the floating
|
||||||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||||
overlay instead of building a new blur/shadow combo.
|
overlay instead of building a new blur/shadow combo.
|
||||||
|
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
|
||||||
|
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
|
||||||
|
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
|
||||||
|
expanded always labelled. Phone/Android layout only.
|
||||||
|
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
|
||||||
|
out past `viewport.height / doc.height`, so for short documents (<= 3
|
||||||
|
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
|
||||||
|
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
|
||||||
|
the boundary height reaches the viewport and fit-width stays reachable
|
||||||
|
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
|
||||||
|
+ top bar height, 0 when the controls are hidden) animates the viewport
|
||||||
|
below the overlaid top bar.
|
||||||
|
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
|
||||||
|
under the label, above the card; `SettingsSection` uses it for its
|
||||||
|
`notice` on phones.
|
||||||
|
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
|
||||||
|
the media viewer's bottom bar as a draggable panel (mobile/phone only;
|
||||||
|
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
|
||||||
|
Details button, or swiping up on swipeable media (raw `Listener` in
|
||||||
|
`file_viewer_screen.dart`) grows it upward so the action row rides on top
|
||||||
|
of the details; tapping the media collapses it.
|
||||||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||||
@@ -208,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
|
||||||
@@ -230,7 +290,11 @@ blocks are noted where they matter:
|
|||||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||||
opens the account switcher. `showAvatarMenu` is this app's first use of
|
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
||||||
|
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
||||||
|
the drawer. The menu header has a chevron that expands an account section
|
||||||
|
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
||||||
|
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
||||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||||
not a modal flow) - there's no existing anchored-popup primitive here
|
not a modal flow) - there's no existing anchored-popup primitive here
|
||||||
@@ -324,3 +388,27 @@ blocks are noted where they matter:
|
|||||||
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
||||||
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
||||||
layer), so it renders at a sensible size at 72-80px.
|
layer), so it renders at a sensible size at 72-80px.
|
||||||
|
|
||||||
|
## Text/markdown viewer
|
||||||
|
|
||||||
|
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||||
|
|
||||||
|
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
||||||
|
|
||||||
|
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||||
|
|
||||||
|
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||||
|
|
||||||
|
## Desktop/tablet layout notes
|
||||||
|
|
||||||
|
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
||||||
|
system bars clear of the sidebar; the sidebar has no divider between pinned
|
||||||
|
and hidden tabs.
|
||||||
|
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
||||||
|
pushed category screens render the desktop card layout.
|
||||||
|
- Settings that don't apply in wide-tablet (Appearance nav options,
|
||||||
|
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
||||||
|
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
||||||
|
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
||||||
|
width, never below the phone count), so a tablet in portrait gets more
|
||||||
|
columns rather than a few huge tiles.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Check PR
|
||||||
|
|
||||||
|
# Runs analyze + tests on pull requests. Deliberately uses no secrets, so it
|
||||||
|
# is safe against imported contributor code. Keep it that way: signing keys
|
||||||
|
# and the Play credentials are only available to the tag-triggered workflows.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter analyze
|
||||||
|
- run: flutter test
|
||||||
@@ -39,6 +39,16 @@ jobs:
|
|||||||
# platform/build-tools versions it actually needs on its own.
|
# platform/build-tools versions it actually needs on its own.
|
||||||
packages: "platform-tools"
|
packages: "platform-tools"
|
||||||
|
|
||||||
|
- name: Check tag matches pubspec version
|
||||||
|
# Play rejects a repeated versionCode and a mismatched tag would
|
||||||
|
# publish the wrong version name, so fail fast before building.
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -67,6 +77,7 @@ jobs:
|
|||||||
mkdir -p release
|
mkdir -p release
|
||||||
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
|
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
|
||||||
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
|
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
|
||||||
|
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Publish Gitea release with App Bundle attached
|
- name: Publish Gitea release with App Bundle attached
|
||||||
# Gitea-native release action (not GitHub's) - authenticates and
|
# Gitea-native release action (not GitHub's) - authenticates and
|
||||||
@@ -77,3 +88,18 @@ jobs:
|
|||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
files: ${{ env.AAB_PATH }}
|
files: ${{ env.AAB_PATH }}
|
||||||
|
|
||||||
|
- name: Upload to Google Play (internal track, auto-publish)
|
||||||
|
# Rolls the build out to internal testers automatically
|
||||||
|
# (`status: completed`); promote it to other tracks in Play Console.
|
||||||
|
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
|
||||||
|
# first release must already have been uploaded by hand.
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: dev.ayushya.noo
|
||||||
|
releaseFiles: ${{ env.AAB_PATH }}
|
||||||
|
releaseName: ${{ env.VERSION }}
|
||||||
|
track: internal
|
||||||
|
status: completed
|
||||||
|
whatsNewDirectory: distribution/whatsnew
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something isn't working as expected
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Noo version
|
||||||
|
description: Settings → About
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device and OS version
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server
|
||||||
|
attributes:
|
||||||
|
label: Nextcloud server version
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Screenshots or logs
|
||||||
|
description: Remove any personal data or server addresses you don't want public.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security problem
|
||||||
|
url: https://github.com/ayushyamitabh/noo/blob/main/SECURITY.md
|
||||||
|
about: Please report security issues privately, not as public issues.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an improvement
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: >
|
||||||
|
Noo aims to be a simple, just-works Nextcloud client with reasonable
|
||||||
|
customization, not a replacement for every Nextcloud feature. Requests
|
||||||
|
that fit that goal are most likely to be accepted.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What problem are you trying to solve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: What would you like to see?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this change, and why? Link the issue it closes, e.g. "Closes #123". -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Screenshots / recordings
|
||||||
|
|
||||||
|
<!-- Required for UI changes: before/after, light + dark, phone + tablet width where relevant. Delete if not applicable. -->
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- How did you verify this? Device/emulator and OS version for anything platform-specific. -->
|
||||||
|
|
||||||
|
- [ ] `flutter analyze` is clean
|
||||||
|
- [ ] `flutter test` passes
|
||||||
|
- [ ] Tried it on a device or emulator (UI / platform changes)
|
||||||
|
- [ ] Tested against a real Nextcloud server (server / sync changes)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] New or changed behavior has tests where practical
|
||||||
|
- [ ] Docs in `.claude/context/` updated if architecture, server integration, styling, or standards changed
|
||||||
|
- [ ] No secrets, server URLs, credentials, or personal data in code, tests, or screenshots
|
||||||
|
- [ ] Commits follow the commit message template (`.gitmessage`)
|
||||||
@@ -45,6 +45,14 @@ app.*.map.json
|
|||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
# Play Store screenshot tooling (tool/screenshots.sh)
|
# Local Claude Code state
|
||||||
/store_listing/screenshots/
|
.claude/worktrees/
|
||||||
/tool/demo_server/.venv/
|
.claude/settings.local.json
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Signing / secrets (never commit)
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
key.properties
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# <Summary: imperative, sentence case, no trailing period, <= 72 chars>
|
||||||
|
#
|
||||||
|
# Why is this change needed? What does it do? Wrap lines at 72 chars.
|
||||||
|
# Explain the "why"; the diff already shows the "what".
|
||||||
|
#
|
||||||
|
# Examples of good summaries:
|
||||||
|
# Fix PDF zoom not returning to fit-width
|
||||||
|
# Add Upload button size setting
|
||||||
|
# Bump to 1.0.2+5
|
||||||
|
#
|
||||||
|
# Closes #<issue>
|
||||||
|
#
|
||||||
|
# Trailers (keep the ones that apply):
|
||||||
|
# Co-Authored-By: Name <email>
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Lines starting with '#' are ignored. Enable this template once with:
|
||||||
|
# git config commit.template .gitmessage
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Contributing to Noo
|
||||||
|
|
||||||
|
Thanks for your interest in improving Noo. Bug reports, ideas, and pull
|
||||||
|
requests are welcome.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- For anything bigger than a small fix, open an issue first so we can agree on
|
||||||
|
the approach before you spend time on it.
|
||||||
|
- Read [`CLAUDE.md`](CLAUDE.md) and the topic files in
|
||||||
|
[`.claude/context/`](.claude/context): architecture, server integration,
|
||||||
|
styling, and code standards. They apply to human and AI contributors alike.
|
||||||
|
- Never include real server URLs, usernames, app passwords, or personal files
|
||||||
|
in issues, tests, or screenshots.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
You need a Nextcloud server to sign in to. A throwaway local instance is best
|
||||||
|
for testing anything destructive (delete, move, trash).
|
||||||
|
|
||||||
|
## Making a change
|
||||||
|
|
||||||
|
1. Branch from `main`.
|
||||||
|
2. Keep the change focused; unrelated cleanup belongs in its own PR.
|
||||||
|
3. Run the checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Format only the files you touched (`dart format <files>`), not the whole
|
||||||
|
tree.
|
||||||
|
5. If you changed architecture, server integration, styling conventions, or
|
||||||
|
standards, update the matching file in `.claude/context/` in the same PR.
|
||||||
|
6. Open a pull request and fill in the template.
|
||||||
|
|
||||||
|
## How PRs and issues are handled
|
||||||
|
|
||||||
|
Issues and pull requests live on GitHub. The maintainer's source of truth is
|
||||||
|
a self-hosted Gitea instance: open PRs are imported there, reviewed, and
|
||||||
|
merged there, and `main` is then mirrored back to GitHub. Two things follow:
|
||||||
|
|
||||||
|
- Keep discussion on the GitHub PR; that is where I'll reply.
|
||||||
|
- A merged change may show up on GitHub as the PR being closed with a link to
|
||||||
|
the commit rather than as "Merged". Your authorship is preserved.
|
||||||
|
- Imported PRs run `flutter analyze` and `flutter test` on my server, so
|
||||||
|
please run both locally first.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Use the repository's template so history stays consistent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config commit.template .gitmessage
|
||||||
|
```
|
||||||
|
|
||||||
|
Write an imperative, sentence-case summary of 72 characters or fewer, then a
|
||||||
|
blank line and a short explanation of *why* when it isn't obvious.
|
||||||
|
|
||||||
|
## License of contributions
|
||||||
|
|
||||||
|
Noo is licensed under the [PolyForm Shield License 1.0.0](LICENSE). By
|
||||||
|
submitting a contribution you confirm that you wrote it (or have the right to
|
||||||
|
submit it), and you license it to the maintainer and everyone who receives the
|
||||||
|
project under those same terms. You also grant the maintainer the right to
|
||||||
|
relicense the project, including your contribution, in the future.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Please do not file security problems as public issues. Email
|
||||||
|
[hello@ayushya.dev](mailto:hello@ayushya.dev) with the details and I'll
|
||||||
|
respond as soon as I can.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
Required Notice: Copyright (c) 2026 Ayushya (https://ayushya.dev)
|
||||||
|
|
||||||
|
# PolyForm Shield License 1.0.0
|
||||||
|
|
||||||
|
<https://polyformproject.org/licenses/shield/1.0.0>
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||||
|
|
||||||
|
## Copyright License
|
||||||
|
|
||||||
|
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Distribution License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Notices
|
||||||
|
|
||||||
|
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||||
|
|
||||||
|
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||||
|
|
||||||
|
## Changes and New Works License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||||
|
|
||||||
|
## Patent License
|
||||||
|
|
||||||
|
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||||
|
|
||||||
|
## Noncompete
|
||||||
|
|
||||||
|
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
||||||
|
|
||||||
|
## Competition
|
||||||
|
|
||||||
|
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
||||||
|
|
||||||
|
## New Products
|
||||||
|
|
||||||
|
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
||||||
|
|
||||||
|
## Discontinued Products
|
||||||
|
|
||||||
|
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
||||||
|
|
||||||
|
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
||||||
|
|
||||||
|
## Sales of Business
|
||||||
|
|
||||||
|
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
||||||
|
|
||||||
|
## Fair Use
|
||||||
|
|
||||||
|
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||||
|
|
||||||
|
## No Other Rights
|
||||||
|
|
||||||
|
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||||
|
|
||||||
|
## Patent Defense
|
||||||
|
|
||||||
|
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||||
|
|
||||||
|
## Violations
|
||||||
|
|
||||||
|
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||||
|
|
||||||
|
## No Liability
|
||||||
|
|
||||||
|
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||||
|
|
||||||
|
A **product** can be a good or service, or a combination of them.
|
||||||
|
|
||||||
|
**You** refers to the individual or entity agreeing to these terms.
|
||||||
|
|
||||||
|
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
||||||
|
|
||||||
|
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
||||||
|
|
||||||
|
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||||
|
|
||||||
|
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||||
|
|
||||||
|
**Use** means anything you do with the software requiring one of your licenses.
|
||||||
@@ -1,36 +1,46 @@
|
|||||||
# Noo
|
# Noo
|
||||||
|
|
||||||
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
|
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
|
||||||
and Material You.
|
and Material 3.
|
||||||
|
|
||||||
Noo gives you a clean, modern way to browse and manage the files on your own
|
Noo is a clean, modern way to browse and manage the files on your own
|
||||||
Nextcloud server — files, photos, shares, activity, and trash, all in one
|
Nextcloud server: files, photos, shares, activity, and trash in one app,
|
||||||
app, themed to match your device.
|
themed to match your device and laid out for both phones and tablets.
|
||||||
|
|
||||||
|
Website: <https://noo.ayushya.dev>
|
||||||
|
|
||||||
|
> Noo is an independent, unofficial client. It is not affiliated with or
|
||||||
|
> endorsed by Nextcloud GmbH.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files** — browse, upload, move, rename, delete, and favorite files and
|
- **Files**: browse, upload, move, copy, rename, delete, and favorite files
|
||||||
folders; sort and filter per folder
|
and folders; sort and filter per folder; list or grid view
|
||||||
- **Photos** — a dedicated media gallery with a full-screen viewer for images
|
- **Photos**: a media gallery with a full-screen viewer for images and video
|
||||||
and video
|
- **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
|
||||||
- **Activity** — a feed of what's changed on your server
|
pull-up details panel (info, activity, sharing)
|
||||||
- **Shares** — see and manage what you've shared and what's shared with you
|
- **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
|
||||||
- **Trash** — restore or permanently delete recently removed items
|
have shared, what changed, and what you deleted
|
||||||
- **Material You theming** — dynamic color on supported devices, five
|
- **Search** across your server
|
||||||
built-in accent colors, light/dark/AMOLED
|
- **Offline and sync**: keep files available offline and sync chosen folders
|
||||||
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign
|
in the background
|
||||||
in through your browser, and Noo never sees or stores your password,
|
- **Multiple accounts**: switch between saved accounts from the avatar menu
|
||||||
only a scoped app token
|
- **App lock**: optionally require device authentication to open the app,
|
||||||
|
switch accounts, or reveal hidden files
|
||||||
## Screenshots
|
- **Share to Noo**: upload from any app's share sheet
|
||||||
|
- **Make it yours**: Material You dynamic color, five accent colors,
|
||||||
_Coming soon._
|
light/dark/AMOLED, attached or floating bottom bar, hamburger or avatar
|
||||||
|
navigation, adjustable upload button, and a tablet layout with a sidebar
|
||||||
|
- **Secure sign-in**: authenticates through Nextcloud's Login Flow v2. You
|
||||||
|
sign in through your browser, and Noo never sees or stores your password,
|
||||||
|
only a scoped app password
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
|
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
|
||||||
|
- An Android device or emulator, an iOS device, or Windows
|
||||||
- A Nextcloud server with Login Flow v2 enabled (on by default since
|
- A Nextcloud server with Login Flow v2 enabled (on by default since
|
||||||
Nextcloud 15)
|
Nextcloud 15)
|
||||||
|
|
||||||
@@ -39,38 +49,83 @@ flutter pub get
|
|||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
On first launch, enter your server's address — Noo opens your browser to
|
On first launch, enter your server's address. Noo opens your browser to
|
||||||
finish signing in.
|
finish signing in.
|
||||||
|
|
||||||
## Supported platforms
|
|
||||||
|
|
||||||
Android, iOS, and Windows.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter build apk # Android
|
flutter build apk # Android (debug-signed unless you configure a key)
|
||||||
flutter build ios # iOS
|
flutter build ios # iOS
|
||||||
flutter build windows # Windows
|
flutter build windows # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Release builds read signing details from `android/key.properties` or from
|
||||||
|
`RELEASE_KEYSTORE_*` environment variables (see `android/app/build.gradle.kts`).
|
||||||
|
Neither the keystore nor `key.properties` is, or should ever be, committed.
|
||||||
|
Without them, release builds fall back to debug signing.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
||||||
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV
|
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
|
||||||
and OCS APIs — no bundled Nextcloud SDK.
|
OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
|
||||||
|
transfers and background sync.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Project context for contributors (human or AI) lives in
|
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||||
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server
|
first. In short:
|
||||||
integration, styling conventions, and code standards.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter test # run tests
|
flutter test # run tests
|
||||||
flutter analyze # static analysis / lints
|
flutter analyze # static analysis / lints
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Architecture, server integration, styling conventions, and code standards are
|
||||||
|
documented in [`CLAUDE.md`](CLAUDE.md) and `.claude/context/`, written for
|
||||||
|
both human and AI contributors.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Noo talks only to the Nextcloud server you point it at. It has no analytics
|
||||||
|
and no third-party tracking. Read the full [privacy policy](https://noo.ayushya.dev/privacy).
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Typefaces [Instrument Sans](https://fonts.google.com/specimen/Instrument+Sans)
|
||||||
|
and [Schibsted Grotesk](https://fonts.google.com/specimen/Schibsted+Grotesk)
|
||||||
|
are bundled under the SIL Open Font License, and icons are from
|
||||||
|
[Lucide](https://lucide.dev). Those keep their own licenses; the license
|
||||||
|
below covers Noo's own code and assets.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Not yet licensed for redistribution — license TBD.
|
Noo is source-available under the
|
||||||
|
[PolyForm Shield License 1.0.0](LICENSE). In plain terms: you may read, build,
|
||||||
|
run, and modify the code, and contribute changes back, but you may not use it
|
||||||
|
to make a product or service that competes with Noo. Please do not republish
|
||||||
|
it as your own app.
|
||||||
|
|
||||||
|
Contributions are welcome under the terms in
|
||||||
|
[`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.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
If you find a security problem in Noo, please email
|
||||||
|
[hello@ayushya.dev](mailto:hello@ayushya.dev) instead of opening a public
|
||||||
|
issue. Include steps to reproduce and the version (`pubspec.yaml`). I'll
|
||||||
|
acknowledge reports as soon as I can and credit you in the fix if you'd like.
|
||||||
|
|
||||||
|
Never include real server URLs, credentials, or personal files in a report.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
// Drives the real app through its tabs and states against the fake-data demo
|
|
||||||
// server and captures the Play Store screenshots. Not part of the normal test
|
|
||||||
// suite (`flutter test` only runs `test/`) - run it through
|
|
||||||
// `tool/screenshots.sh`, which starts the demo server, an emulator and the
|
|
||||||
// driver in `test_driver/integration_test.dart` that saves the images.
|
|
||||||
//
|
|
||||||
// The app starts already logged in: the demo user's credentials are written
|
|
||||||
// straight into storage before `main()` runs, so Login Flow v2's browser
|
|
||||||
// step is skipped.
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:integration_test/integration_test.dart';
|
|
||||||
import 'package:noo/main.dart' as app;
|
|
||||||
import 'package:noo/models/app_tab.dart';
|
|
||||||
import 'package:noo/models/saved_account.dart';
|
|
||||||
import 'package:noo/providers/files_controller.dart';
|
|
||||||
import 'package:noo/providers/settings_controller.dart';
|
|
||||||
import 'package:noo/providers/sync_status_controller.dart';
|
|
||||||
import 'package:noo/services/account_store.dart';
|
|
||||||
import 'package:noo/theme/design_tokens.dart';
|
|
||||||
import 'package:noo/widgets/bottom_nav_bar.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
const _serverUrl = String.fromEnvironment(
|
|
||||||
'DEMO_SERVER_URL',
|
|
||||||
defaultValue: 'http://localhost',
|
|
||||||
);
|
|
||||||
const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex');
|
|
||||||
const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD');
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'Play Store screenshots',
|
|
||||||
(tester) async {
|
|
||||||
expect(
|
|
||||||
_appPassword,
|
|
||||||
isNotEmpty,
|
|
||||||
reason: 'Pass the demo user\'s app password via '
|
|
||||||
'--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).',
|
|
||||||
);
|
|
||||||
await _seedLoggedInStorage();
|
|
||||||
|
|
||||||
app.main();
|
|
||||||
await tester.pump();
|
|
||||||
await _pumpUntil(tester, find.byType(app.MainShellView));
|
|
||||||
|
|
||||||
// Android only: required before `takeScreenshot()` can capture
|
|
||||||
// anything at all.
|
|
||||||
if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage();
|
|
||||||
|
|
||||||
Future<void> shoot(String name) async {
|
|
||||||
// Let scroll/transition animations and image fades finish first.
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
await binding.takeScreenshot(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 01 Files (list) ------------------------------------------------
|
|
||||||
final filesTab = find.byKey(const ValueKey('files'));
|
|
||||||
await _pumpUntil(
|
|
||||||
tester,
|
|
||||||
find.descendant(of: filesTab, matching: find.text('Documents')),
|
|
||||||
);
|
|
||||||
await shoot('01_Files');
|
|
||||||
|
|
||||||
// ---- 02 Files (grid, inside a folder of photos) --------------------
|
|
||||||
final files = _read<FilesController>(tester);
|
|
||||||
await files.navigateToAbsoluteFolder('/Trips');
|
|
||||||
await _pumpUntil(
|
|
||||||
tester,
|
|
||||||
find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')),
|
|
||||||
);
|
|
||||||
// The controls row scrolls horizontally and doesn't fit every control
|
|
||||||
// on a narrow screen, so "Grid view" can start off past the clipped
|
|
||||||
// edge - not just off the *current* tab (which `.hitTestable()` alone
|
|
||||||
// would handle), but off the *visible part of this tab's own row*.
|
|
||||||
// `_tapInTab` scrolls it into view first.
|
|
||||||
await _tapInTab(tester, filesTab, find.byTooltip('Grid view'));
|
|
||||||
await tester.pump(const Duration(seconds: 4)); // thumbnails
|
|
||||||
await shoot('02_Files_Grid');
|
|
||||||
|
|
||||||
// ---- 07 File viewer -------------------------------------------------
|
|
||||||
await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg'));
|
|
||||||
await tester.pump(const Duration(seconds: 4));
|
|
||||||
await shoot('07_Viewer');
|
|
||||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// Back to the root folder, list view.
|
|
||||||
await _tapInTab(tester, filesTab, find.byTooltip('List view'));
|
|
||||||
await files.navigateToAbsoluteFolder('/');
|
|
||||||
await tester.pump(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
// ---- 03 Photos ------------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.photos);
|
|
||||||
await tester.pump(const Duration(seconds: 5)); // grid + thumbnails
|
|
||||||
await shoot('03_Photos');
|
|
||||||
|
|
||||||
// ---- 04 Favorites ---------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.favorites);
|
|
||||||
await _pumpUntil(tester, find.text('Project brief.pdf'));
|
|
||||||
await shoot('04_Favorites');
|
|
||||||
|
|
||||||
// ---- 05 Activity ----------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.activity);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('05_Activity');
|
|
||||||
|
|
||||||
// ---- 06 Shares ------------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.shares);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('06_Shares');
|
|
||||||
|
|
||||||
// ---- 08 Sync status panel ------------------------------------------
|
|
||||||
// Sync two folders to the device; the header chip flips to "Synced"
|
|
||||||
// once the native worker has finished, and tapping it opens the panel.
|
|
||||||
await _goToTab(tester, AppTab.files);
|
|
||||||
await _read<SyncStatusController>(
|
|
||||||
tester,
|
|
||||||
).addSyncedPaths({'/Documents': true, '/Trips': true});
|
|
||||||
await _pumpUntil(tester, find.text('Synced'), timeout: 180);
|
|
||||||
await _tapInTab(tester, filesTab, find.text('Synced'));
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
await shoot('08_Sync_Status');
|
|
||||||
await _tapInTab(tester, filesTab, find.byTooltip('Collapse'));
|
|
||||||
|
|
||||||
// ---- 09 Offline -----------------------------------------------------
|
|
||||||
_setVisibleTabs(tester, [
|
|
||||||
AppTab.files,
|
|
||||||
AppTab.photos,
|
|
||||||
AppTab.favorites,
|
|
||||||
AppTab.shares,
|
|
||||||
AppTab.offline,
|
|
||||||
]);
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
await _goToTab(tester, AppTab.offline);
|
|
||||||
await _pumpUntil(
|
|
||||||
tester,
|
|
||||||
find.descendant(
|
|
||||||
of: find.byKey(const ValueKey('offline')),
|
|
||||||
matching: find.text('Documents'),
|
|
||||||
),
|
|
||||||
timeout: 120,
|
|
||||||
);
|
|
||||||
await shoot('09_Offline');
|
|
||||||
|
|
||||||
// ---- 10/11 Settings -------------------------------------------------
|
|
||||||
await _tap(tester, find.byTooltip('Settings').hitTestable().first);
|
|
||||||
await _pumpUntil(tester, find.text('Accounts'));
|
|
||||||
await shoot('10_Settings');
|
|
||||||
// Wait for it, rather than assuming it's already built - ensureVisible
|
|
||||||
// itself has no retry/timeout, so on any transient delay (e.g. still
|
|
||||||
// mid-route-transition) it fails immediately instead of giving the
|
|
||||||
// frame a moment to catch up.
|
|
||||||
await _pumpUntil(tester, find.text('Files Cache'));
|
|
||||||
await tester.ensureVisible(find.text('Files Cache'));
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await shoot('11_Settings_Sync');
|
|
||||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// ---- 12/13 Dark theme -------------------------------------------------
|
|
||||||
final settings = _read<SettingsController>(tester);
|
|
||||||
settings.setThemeMode(ThemeMode.dark);
|
|
||||||
await _goToTab(tester, AppTab.files);
|
|
||||||
await tester.pump(const Duration(seconds: 2));
|
|
||||||
await shoot('12_Files_Dark');
|
|
||||||
await _goToTab(tester, AppTab.photos);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('13_Photos_Dark');
|
|
||||||
|
|
||||||
// ---- 14/15 Dark theme, AMOLED (pure black) -----------------------------
|
|
||||||
settings.setAmoledDark(true);
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
await _goToTab(tester, AppTab.files);
|
|
||||||
await tester.pump(const Duration(seconds: 2));
|
|
||||||
await shoot('14_Files_Dark_Amoled');
|
|
||||||
await _goToTab(tester, AppTab.photos);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('15_Photos_Dark_Amoled');
|
|
||||||
},
|
|
||||||
timeout: const Timeout(Duration(minutes: 15)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts from empty storage, then writes exactly one saved account (the
|
|
||||||
/// demo user, with the app password from `--dart-define`) and the settings
|
|
||||||
/// that make screenshots reproducible: light theme, a fixed brand colour
|
|
||||||
/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav
|
|
||||||
/// tabs, and no first-run notification prompt.
|
|
||||||
Future<void> _seedLoggedInStorage() async {
|
|
||||||
await const FlutterSecureStorage().deleteAll();
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.clear();
|
|
||||||
|
|
||||||
final store = AccountStore();
|
|
||||||
final account = SavedAccount(
|
|
||||||
id: SavedAccount.makeId(_serverUrl, _username),
|
|
||||||
serverUrl: _serverUrl,
|
|
||||||
username: _username,
|
|
||||||
);
|
|
||||||
await store.saveAccounts(prefs, [account]);
|
|
||||||
await store.saveActiveAccountId(prefs, account.id);
|
|
||||||
await store.writePassword(account.id, _appPassword);
|
|
||||||
|
|
||||||
await prefs.setBool('notification_permission_asked', true);
|
|
||||||
await prefs.setString('ui_theme_mode', ThemeMode.light.name);
|
|
||||||
await prefs.setBool('ui_use_dynamic_color', false);
|
|
||||||
await prefs.setStringList('ui_tab_order', [
|
|
||||||
for (final t in AppTab.values) t.name,
|
|
||||||
]);
|
|
||||||
await prefs.setStringList('ui_hidden_tabs', [
|
|
||||||
AppTab.trash.name,
|
|
||||||
AppTab.recent.name,
|
|
||||||
AppTab.offline.name,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
T _read<T>(WidgetTester tester) => Provider.of<T>(
|
|
||||||
tester.element(find.byType(app.MainShellView)),
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _tap(WidgetTester tester, Finder finder) async {
|
|
||||||
await tester.tap(finder);
|
|
||||||
await tester.pump(const Duration(milliseconds: 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) -
|
|
||||||
/// for chrome that's duplicated across tabs (`IndexedStack` keeps every
|
|
||||||
/// visible tab's widget tree mounted, not just the active one) and/or can be
|
|
||||||
/// scrolled out of its own row's clipped viewport (the controls row,
|
|
||||||
/// `FilesControlsRow`, doesn't fit every control on a narrow screen).
|
|
||||||
/// Scoping to [tab] picks the right one of several identical matches;
|
|
||||||
/// `ensureVisible` scrolls it into view before tapping, which plain
|
|
||||||
/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look
|
|
||||||
/// like "zero hit-testable matches" and throw the same unhelpful
|
|
||||||
/// `Bad state: No element`.
|
|
||||||
Future<void> _tapInTab(WidgetTester tester, Finder tab, Finder matching) async {
|
|
||||||
final target = find.descendant(of: tab, matching: matching).first;
|
|
||||||
await tester.ensureVisible(target);
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await tester.tap(target, warnIfMissed: false);
|
|
||||||
await tester.pump(const Duration(milliseconds: 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _goToTab(WidgetTester tester, AppTab tab) async {
|
|
||||||
// Only one bottom nav bar exists (outside the IndexedStack), so no
|
|
||||||
// cross-tab duplicate to worry about - but it can itself overflow a
|
|
||||||
// narrow screen, so still scroll the target into view rather than relying
|
|
||||||
// on hitTestable() alone (see `_tapInTab`'s doc comment).
|
|
||||||
final target = find
|
|
||||||
.descendant(
|
|
||||||
of: find.byType(BottomNavBar),
|
|
||||||
matching: find.byIcon(tab.icon),
|
|
||||||
)
|
|
||||||
.first;
|
|
||||||
await tester.ensureVisible(target);
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await tester.tap(target, warnIfMissed: false);
|
|
||||||
await tester.pump(const Duration(milliseconds: 600));
|
|
||||||
|
|
||||||
// Confirm the tap actually landed, rather than trusting it silently - a
|
|
||||||
// scroll-imprecise tap (see above) that misses its target doesn't throw,
|
|
||||||
// it just does nothing, which would otherwise leave every later
|
|
||||||
// screenshot quietly showing whatever tab was already selected instead of
|
|
||||||
// failing loudly. Every destination shows its label all the time now (see
|
|
||||||
// `BottomNavBar`'s doc comment), so label presence can't signal selection
|
|
||||||
// - the icon's color can: it's `accentText` only for the active tab.
|
|
||||||
await _pumpUntilTrue(tester, () {
|
|
||||||
if (target.evaluate().isEmpty) return false;
|
|
||||||
final icon = tester.widget<Icon>(target);
|
|
||||||
final colors = tester.element(target).nooColors;
|
|
||||||
return icon.color == colors.accentText;
|
|
||||||
}, timeout: 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the
|
|
||||||
/// five-tab cap is never hit).
|
|
||||||
void _setVisibleTabs(WidgetTester tester, List<AppTab> tabs) {
|
|
||||||
final settings = _read<SettingsController>(tester);
|
|
||||||
for (final tab in AppTab.values) {
|
|
||||||
if (!tabs.contains(tab)) settings.setTabHidden(tab, true);
|
|
||||||
}
|
|
||||||
for (final tab in tabs) {
|
|
||||||
settings.setTabHidden(tab, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits (in real time - this is a live binding, and the app is talking to a
|
|
||||||
/// real server) until [finder] matches something.
|
|
||||||
Future<void> _pumpUntil(
|
|
||||||
WidgetTester tester,
|
|
||||||
Finder finder, {
|
|
||||||
int timeout = 60,
|
|
||||||
}) async {
|
|
||||||
final deadline = DateTime.now().add(Duration(seconds: timeout));
|
|
||||||
while (finder.evaluate().isEmpty) {
|
|
||||||
if (DateTime.now().isAfter(deadline)) {
|
|
||||||
throw TestFailure('Timed out after ${timeout}s waiting for $finder');
|
|
||||||
}
|
|
||||||
await tester.pump(const Duration(milliseconds: 250));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [_pumpUntil], but for a condition that isn't just "does a finder
|
|
||||||
/// match something" (e.g. a widget property).
|
|
||||||
Future<void> _pumpUntilTrue(
|
|
||||||
WidgetTester tester,
|
|
||||||
bool Function() predicate, {
|
|
||||||
int timeout = 60,
|
|
||||||
}) async {
|
|
||||||
final deadline = DateTime.now().add(Duration(seconds: timeout));
|
|
||||||
while (!predicate()) {
|
|
||||||
if (DateTime.now().isAfter(deadline)) {
|
|
||||||
throw TestFailure('Timed out after ${timeout}s waiting for condition');
|
|
||||||
}
|
|
||||||
await tester.pump(const Duration(milliseconds: 250));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,35 @@
|
|||||||
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 */; };
|
||||||
|
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 +46,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,16 +80,31 @@
|
|||||||
/* 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>"; };
|
||||||
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; };
|
||||||
@@ -59,9 +112,18 @@
|
|||||||
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 +135,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 +151,43 @@
|
|||||||
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 */,
|
||||||
|
);
|
||||||
|
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 +207,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 +218,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 +235,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 +273,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 +284,7 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -171,6 +294,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 +349,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
F9BE9B2F18B10324D7B84776 /* ShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -221,6 +362,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 +425,22 @@
|
|||||||
);
|
);
|
||||||
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 */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -284,6 +448,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 +468,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 +539,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 +554,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 +617,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 +749,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 +800,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 +817,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 +840,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 +889,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6BF252CEA7A55024A2F58A36 /* Release */,
|
||||||
|
6FB3F72FB5A94DE254FF4FBF /* Debug */,
|
||||||
|
3B057BF66761E30DEABF8B30 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
@@ -7,10 +8,38 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
TransferManager.shared.reconnect()
|
||||||
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
return launched
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer summary ("Downloaded 1 file") usually finishes while the app
|
||||||
|
/// is open; without this iOS delivers it silently to Notification Center
|
||||||
|
/// instead of showing the banner.
|
||||||
|
override func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .list, .sound])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier {
|
||||||
|
TransferManager.shared.backgroundCompletionHandler = completionHandler
|
||||||
|
} else {
|
||||||
|
super.application(
|
||||||
|
application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -53,6 +53,16 @@
|
|||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<key>NooKeychainAccessGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Noo needs access to your photos and videos so you can choose which ones to upload to your server.</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import Flutter
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Holds the one native-side listener of an `EventChannel` so Swift code can
|
||||||
|
/// push events to Dart whenever they happen.
|
||||||
|
final class SinkStreamHandler: NSObject, FlutterStreamHandler {
|
||||||
|
var sink: FlutterEventSink?
|
||||||
|
|
||||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||||
|
-> FlutterError?
|
||||||
|
{
|
||||||
|
sink = events
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
|
sink = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to
|
||||||
|
/// (Android's lives in `MainActivity.kt`). Upload, download and share (the
|
||||||
|
/// receiving half of the Share Extension) are implemented so far; the rest
|
||||||
|
/// still throw `MissingPluginException`, which the Dart side treats as
|
||||||
|
/// "unavailable" (`lib/services/native_channel.dart`).
|
||||||
|
enum NativeServices {
|
||||||
|
private static let uploadStatus = SinkStreamHandler()
|
||||||
|
|
||||||
|
static func register(messenger: FlutterBinaryMessenger) {
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) }
|
||||||
|
handle(call, result) { args in
|
||||||
|
let files = try parseJSONList(args["files"])
|
||||||
|
try TransferManager.shared.startUpload(
|
||||||
|
serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"),
|
||||||
|
files: files.compactMap { entry in
|
||||||
|
guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UploadFile(uri: uri, name: name)
|
||||||
|
},
|
||||||
|
remoteFolder: (args["remoteFolder"] as? String) ?? "/"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) }
|
||||||
|
handle(call, result) { args in
|
||||||
|
let items = try parseJSONList(args["files"])
|
||||||
|
try TransferManager.shared.startDownload(
|
||||||
|
serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"),
|
||||||
|
items: items.compactMap { entry in
|
||||||
|
guard let path = entry["path"] as? String, let name = entry["name"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (path: path, name: name)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger)
|
||||||
|
.setStreamHandler(uploadStatus)
|
||||||
|
TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in
|
||||||
|
uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed])
|
||||||
|
}
|
||||||
|
TransferManager.shared.reconnect()
|
||||||
|
registerShare(messenger: messenger)
|
||||||
|
registerShareAccount(messenger: messenger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account for the Share Extension
|
||||||
|
|
||||||
|
/// `share_account`: Dart tells native which account is active so the Share
|
||||||
|
/// Extension - a separate process with no Flutter engine - can list folders
|
||||||
|
/// and upload as it. 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 "setAccount":
|
||||||
|
handle(call, result) { args in
|
||||||
|
try SharedAccountStore.save(
|
||||||
|
SharedAccount(
|
||||||
|
serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"),
|
||||||
|
displayName: (args["displayName"] as? String) ?? ""
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case "clearAccount":
|
||||||
|
SharedAccountStore.clear()
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share Extension inbox
|
||||||
|
|
||||||
|
private static let shareEvents = SinkStreamHandler()
|
||||||
|
|
||||||
|
/// `share_intent`: files the Share Extension left in the App Group inbox.
|
||||||
|
/// Cold start asks once (`getInitialShare`); a share that arrives while the
|
||||||
|
/// app is running is pushed when it next becomes active (the extension
|
||||||
|
/// can't signal the app any other way).
|
||||||
|
private static func registerShare(messenger: FlutterBinaryMessenger) {
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) }
|
||||||
|
result(consumeShared().map(encode))
|
||||||
|
}
|
||||||
|
FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger)
|
||||||
|
.setStreamHandler(shareEvents)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||||
|
) { _ in
|
||||||
|
TransferManager.shared.deliverExtensionUploads()
|
||||||
|
guard let sink = shareEvents.sink else { return }
|
||||||
|
let items = consumeShared()
|
||||||
|
if !items.isEmpty { sink(items.map(encode)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func consumeShared() -> [SharedItem] {
|
||||||
|
guard let root = AppGroup.containerURL else { return [] }
|
||||||
|
return SharedInbox.consume(in: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encode(_ item: SharedItem) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"uri": URL(fileURLWithPath: item.path).absoluteString,
|
||||||
|
"name": item.name,
|
||||||
|
"mimeType": item.mimeType ?? NSNull(),
|
||||||
|
"size": item.size,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Argument helpers
|
||||||
|
|
||||||
|
/// Runs [work] with the call's argument map, turning a thrown error into
|
||||||
|
/// the `FlutterError` Dart's `catch` shows the user.
|
||||||
|
private static func handle(
|
||||||
|
_ call: FlutterMethodCall,
|
||||||
|
_ result: @escaping FlutterResult,
|
||||||
|
work: ([String: Any]) throws -> Void
|
||||||
|
) {
|
||||||
|
guard let args = call.arguments as? [String: Any] else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try work(args)
|
||||||
|
result(nil)
|
||||||
|
} catch {
|
||||||
|
result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func string(_ args: [String: Any], _ key: String) throws -> String {
|
||||||
|
guard let value = args[key] as? String, !value.isEmpty else {
|
||||||
|
throw TransferError(message: "Missing \(key).")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Dart services pass their file list as one JSON string.
|
||||||
|
private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] {
|
||||||
|
guard let json = value as? String,
|
||||||
|
let data = json.data(using: .utf8),
|
||||||
|
let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
else {
|
||||||
|
throw TransferError(message: "Missing file list.")
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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
|
||||||
|
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The session the Share Extension's uploads run on (see `ShareUpload`).
|
||||||
|
/// The extension is gone by the time they finish, so the app recreates it -
|
||||||
|
/// same identifier and shared container - to receive the results.
|
||||||
|
private lazy var shareSession = URLSession(
|
||||||
|
configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil)
|
||||||
|
|
||||||
|
/// Touch both sessions at launch so a system relaunch for finished
|
||||||
|
/// background transfers reconnects to them.
|
||||||
|
func reconnect() {
|
||||||
|
_ = session
|
||||||
|
_ = shareSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Starting transfers
|
||||||
|
|
||||||
|
func startUpload(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
authHeader: String,
|
||||||
|
files: [UploadFile],
|
||||||
|
remoteFolder: String
|
||||||
|
) throws {
|
||||||
|
guard !files.isEmpty else { throw TransferError(message: "No files to upload.") }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
var tasks: [URLSessionUploadTask] = []
|
||||||
|
for (index, file) in files.enumerated() {
|
||||||
|
guard let source = URL(string: file.uri), source.isFileURL else {
|
||||||
|
throw TransferError(message: "Can't read \(file.name).")
|
||||||
|
}
|
||||||
|
let staged = stageDir.appendingPathComponent("\(index)-\(file.name)")
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: source, to: staged)
|
||||||
|
// A file the Share Extension left in the App Group is ours to clean
|
||||||
|
// up once it's staged; a picker's temp file isn't.
|
||||||
|
if source.path.contains("/\(SharedInbox.folderName)/") {
|
||||||
|
try? FileManager.default.removeItem(at: source)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard let url = WebDAV.fileURL(
|
||||||
|
serverUrl: serverUrl,
|
||||||
|
username: username,
|
||||||
|
remotePath: WebDAV.join(remoteFolder, file.name)
|
||||||
|
) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date {
|
||||||
|
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||||
|
}
|
||||||
|
let task = session.uploadTask(with: request, fromFile: staged)
|
||||||
|
task.taskDescription = Self.encode(
|
||||||
|
TransferTaskInfo(
|
||||||
|
kind: .upload, batch: batch, name: file.name,
|
||||||
|
remoteFolder: remoteFolder, stagedPath: staged.path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch
|
||||||
|
)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDownload(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
authHeader: String,
|
||||||
|
items: [(path: String, name: String)]
|
||||||
|
) throws {
|
||||||
|
guard !items.isEmpty else { throw TransferError(message: "No files to download.") }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
var tasks: [URLSessionDownloadTask] = []
|
||||||
|
for item in items {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
let task = session.downloadTask(with: request)
|
||||||
|
task.taskDescription = Self.encode(
|
||||||
|
TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil)
|
||||||
|
)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch
|
||||||
|
)
|
||||||
|
TransferNotifications.started(kind: .download, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locations
|
||||||
|
|
||||||
|
private static func stagingDirectory() throws -> URL {
|
||||||
|
let caches = try FileManager.default.url(
|
||||||
|
for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
||||||
|
)
|
||||||
|
return caches.appendingPathComponent("uploads", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func downloadsDirectory() throws -> URL {
|
||||||
|
let documents = try FileManager.default.url(
|
||||||
|
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
||||||
|
)
|
||||||
|
let dir = documents.appendingPathComponent("Downloads", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch bookkeeping
|
||||||
|
|
||||||
|
private static func encode(_ info: TransferTaskInfo) -> String? {
|
||||||
|
(try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func info(for task: URLSessionTask) -> TransferTaskInfo? {
|
||||||
|
task.taskDescription
|
||||||
|
.flatMap { $0.data(using: .utf8) }
|
||||||
|
.flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one file's outcome; fires the batch summary when it was the last.
|
||||||
|
private func record(_ info: TransferTaskInfo, success: Bool) {
|
||||||
|
if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ batch: TransferBatch, id: String) {
|
||||||
|
TransferNotifications.finished(batch, id: id)
|
||||||
|
reportToDart(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells Dart (so `FilesController` can refresh the folder) once an upload
|
||||||
|
/// batch has landed at least one file.
|
||||||
|
private func reportToDart(_ batch: TransferBatch) {
|
||||||
|
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
|
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folders the Share Extension finished uploading into while the app wasn't
|
||||||
|
/// running - call when the app becomes active.
|
||||||
|
func deliverExtensionUploads() {
|
||||||
|
for folder in TransferBatchStore.consumeFinishedUploadFolders() {
|
||||||
|
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URLSession delegates
|
||||||
|
|
||||||
|
extension TransferManager: URLSessionDownloadDelegate {
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
|
||||||
|
) {
|
||||||
|
guard let info = Self.info(for: downloadTask) else { return }
|
||||||
|
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
guard (200..<300).contains(status) else {
|
||||||
|
record(info, success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name)
|
||||||
|
try FileManager.default.moveItem(at: location, to: destination)
|
||||||
|
record(info, success: true)
|
||||||
|
} catch {
|
||||||
|
record(info, success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let info = Self.info(for: task) else { return }
|
||||||
|
switch info.kind {
|
||||||
|
case .upload:
|
||||||
|
if let result = UploadResults.handle(task, error: error), let batch = result.batch {
|
||||||
|
reportToDart(batch)
|
||||||
|
}
|
||||||
|
case .download:
|
||||||
|
// A finished download was already counted in didFinishDownloadingTo;
|
||||||
|
// only a transport failure arrives here.
|
||||||
|
if error != nil { record(info, success: false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.backgroundCompletionHandler?()
|
||||||
|
self.backgroundCompletionHandler = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.ayushya.noo</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,11 +2,202 @@ 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", includeHidden: true).map(\.name),
|
||||||
|
[".git", "Tax Forms", "zeta"])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
func testSharedAccountRoundTripsThroughTheKeychain() throws {
|
||||||
|
let account = SharedAccount(
|
||||||
|
serverUrl: "https://cloud.example.com", username: "alice",
|
||||||
|
authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com")
|
||||||
|
addTeardownBlock { SharedAccountStore.clear() }
|
||||||
|
|
||||||
|
SharedAccountStore.clear()
|
||||||
|
XCTAssertNil(SharedAccountStore.load())
|
||||||
|
try SharedAccountStore.save(account)
|
||||||
|
XCTAssertEqual(SharedAccountStore.load(), account)
|
||||||
|
|
||||||
|
let replacement = SharedAccount(
|
||||||
|
serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob")
|
||||||
|
try SharedAccountStore.save(replacement)
|
||||||
|
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
||||||
|
|
||||||
|
SharedAccountStore.clear()
|
||||||
|
XCTAssertNil(SharedAccountStore.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?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>NooKeychainAccessGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.ayushya.noo</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// State behind the share sheet's folder picker.
|
||||||
|
@MainActor
|
||||||
|
final class ShareModel: ObservableObject {
|
||||||
|
enum Stage: Equatable {
|
||||||
|
case preparing
|
||||||
|
case picking
|
||||||
|
case uploading
|
||||||
|
case noAccount
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var stage: Stage = .preparing
|
||||||
|
@Published var items: [SharedItem] = []
|
||||||
|
@Published var path = "/"
|
||||||
|
@Published var folders: [DavFolder] = []
|
||||||
|
@Published var isLoadingFolders = false
|
||||||
|
@Published var folderError: String?
|
||||||
|
|
||||||
|
let account: SharedAccount?
|
||||||
|
|
||||||
|
/// Set by the view controller: what each button actually does.
|
||||||
|
var onUpload: () -> Void = {}
|
||||||
|
var onSaveForLater: () -> Void = {}
|
||||||
|
var onCancel: () -> Void = {}
|
||||||
|
|
||||||
|
init(account: SharedAccount?) {
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentFolderName: String {
|
||||||
|
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileCountText: String {
|
||||||
|
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ path: String) async {
|
||||||
|
guard let account else { return }
|
||||||
|
self.path = path
|
||||||
|
isLoadingFolders = true
|
||||||
|
folderError = nil
|
||||||
|
defer { isLoadingFolders = false }
|
||||||
|
do {
|
||||||
|
folders = try await DavClient.listFolders(account: account, path: path)
|
||||||
|
} catch {
|
||||||
|
folders = []
|
||||||
|
folderError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openParent() async {
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
await open(parent.isEmpty ? "/" : parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharePickerView: View {
|
||||||
|
@ObservedObject var model: ShareModel
|
||||||
|
|
||||||
|
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 .picking:
|
||||||
|
folderList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var folderList: some View {
|
||||||
|
List {
|
||||||
|
Section(header: Text(model.account?.displayName ?? "")) {
|
||||||
|
if model.path != "/" {
|
||||||
|
Button {
|
||||||
|
Task { await model.openParent() }
|
||||||
|
} label: {
|
||||||
|
Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", 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.folders.isEmpty {
|
||||||
|
Text("No folders here").foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(model.folders, id: \.path) { folder in
|
||||||
|
Button {
|
||||||
|
Task { await model.open(folder.path) }
|
||||||
|
} label: {
|
||||||
|
Label(folder.name, systemImage: "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == .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,148 @@
|
|||||||
|
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 signed-in account the app left in the shared Keychain
|
||||||
|
/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`).
|
||||||
|
/// 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(account: 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
|
||||||
|
}
|
||||||
|
guard model.account != nil else {
|
||||||
|
model.stage = .noAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.stage = .picking
|
||||||
|
await model.open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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.account else { return }
|
||||||
|
do {
|
||||||
|
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||||
|
model.stage = .uploading
|
||||||
|
complete(after: 1.2)
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveForLater() {
|
||||||
|
guard let root = AppGroup.containerURL else { return cancel() }
|
||||||
|
do {
|
||||||
|
try SharedInbox.append(model.items, in: root)
|
||||||
|
notifyOpenNoo(count: model.items.count)
|
||||||
|
complete(after: 0)
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel() {
|
||||||
|
if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) }
|
||||||
|
extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func complete(after delay: TimeInterval) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
|
self.extensionContext?.completeRequest(returningItems: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyOpenNoo(count: Int) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body =
|
||||||
|
count == 1
|
||||||
|
? "Open Noo to choose where to upload 1 file."
|
||||||
|
: "Open Noo to choose where to upload \(count) files."
|
||||||
|
content.sound = .default
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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.
|
||||||
|
struct DavFolder: Equatable {
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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": 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 "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. The requested folder
|
||||||
|
/// itself (which a Depth-1 PROPFIND also returns) and - unless
|
||||||
|
/// [includeHidden] - dot-folders are left out.
|
||||||
|
static func folders(
|
||||||
|
from xml: Data, excluding currentPath: String, includeHidden: Bool = false
|
||||||
|
) -> [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, includeHidden || !name.hasPrefix(".") else { return nil }
|
||||||
|
return DavFolder(name: name, path: path)
|
||||||
|
}
|
||||||
|
.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:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 URLSession.shared.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `PUT` for [item] into [remoteFolder] - shared by the extension's
|
||||||
|
/// [enqueue] and the app's own uploads so both build requests identically.
|
||||||
|
static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? {
|
||||||
|
guard
|
||||||
|
let url = WebDAV.fileURL(
|
||||||
|
serverUrl: account.serverUrl,
|
||||||
|
username: account.username,
|
||||||
|
remotePath: WebDAV.join(remoteFolder, item.name)
|
||||||
|
)
|
||||||
|
else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date {
|
||||||
|
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues every item (already copied into the App Group by the extension)
|
||||||
|
/// and returns once the system has accepted them.
|
||||||
|
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
// Small files can finish while the extension is still on screen; for
|
||||||
|
// those this delegate counts them. Whatever outlives the extension is
|
||||||
|
// reported to the app instead (see `TransferManager`).
|
||||||
|
let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil)
|
||||||
|
|
||||||
|
var tasks: [URLSessionUploadTask] = []
|
||||||
|
for item in items {
|
||||||
|
guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path))
|
||||||
|
task.taskDescription =
|
||||||
|
(try? JSONEncoder().encode(
|
||||||
|
TransferTaskInfo(
|
||||||
|
kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path)
|
||||||
|
)).flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
session.finishTasksAndInvalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferError: LocalizedError {
|
||||||
|
let message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// The signed-in account, as much of it as the Share Extension needs to list
|
||||||
|
/// folders and upload: the app writes it when an account becomes active and
|
||||||
|
/// clears it on sign-out. Compiled into both targets.
|
||||||
|
struct SharedAccount: Codable, Equatable {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the [SharedAccount] 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-account"
|
||||||
|
private static let account = "active"
|
||||||
|
|
||||||
|
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: SharedAccount) 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() -> SharedAccount? {
|
||||||
|
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
|
||||||
|
else { return nil }
|
||||||
|
return try? JSONDecoder().decode(SharedAccount.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clear() {
|
||||||
|
SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The App Group both the app and its Share Extension belong to - the one
|
||||||
|
/// place they can exchange files.
|
||||||
|
enum AppGroup {
|
||||||
|
static let identifier = "group.dev.ayushya.noo"
|
||||||
|
|
||||||
|
static var containerURL: URL? {
|
||||||
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file the Share Extension copied into the group container.
|
||||||
|
struct SharedItem: Codable, Equatable {
|
||||||
|
let path: String
|
||||||
|
let name: String
|
||||||
|
let mimeType: String?
|
||||||
|
let size: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files the Share Extension received, waiting for the app to ask the user
|
||||||
|
/// where to upload them. The extension appends; the app consumes (reads and
|
||||||
|
/// clears) when it launches or becomes active. Compiled into both targets.
|
||||||
|
///
|
||||||
|
/// Layout under `<root>/SharedInbox/`: `pending.json` (the manifest) and one
|
||||||
|
/// `<batch-id>/` folder per share holding the copied files.
|
||||||
|
enum SharedInbox {
|
||||||
|
static let folderName = "SharedInbox"
|
||||||
|
private static let manifestName = "pending.json"
|
||||||
|
|
||||||
|
static func directory(in root: URL) -> URL {
|
||||||
|
root.appendingPathComponent(folderName, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh folder for one share's files.
|
||||||
|
static func makeBatchDirectory(in root: URL) throws -> URL {
|
||||||
|
let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
static func append(_ items: [SharedItem], in root: URL) throws {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
try withManifestLock(in: root) {
|
||||||
|
let existing = (try? readManifest(in: root)) ?? []
|
||||||
|
try writeManifest(existing + items, in: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything pending, dropping entries whose file has gone missing, and
|
||||||
|
/// clears the manifest so each share is delivered once.
|
||||||
|
static func consume(in root: URL) -> [SharedItem] {
|
||||||
|
var items: [SharedItem] = []
|
||||||
|
try? withManifestLock(in: root) {
|
||||||
|
items = ((try? readManifest(in: root)) ?? []).filter {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: manifestURL(in: root))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func manifestURL(in root: URL) -> URL {
|
||||||
|
directory(in: root).appendingPathComponent(manifestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readManifest(in root: URL) throws -> [SharedItem] {
|
||||||
|
let data = try Data(contentsOf: manifestURL(in: root))
|
||||||
|
return try JSONDecoder().decode([SharedItem].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func writeManifest(_ items: [SharedItem], in root: URL) throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory(in: root), withIntermediateDirectories: true)
|
||||||
|
try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extension and the app are separate processes that can both touch the
|
||||||
|
/// manifest, so read-modify-write goes through a file coordinator.
|
||||||
|
private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory(in: root), withIntermediateDirectories: true)
|
||||||
|
var coordinationError: NSError?
|
||||||
|
var thrown: Error?
|
||||||
|
NSFileCoordinator().coordinate(
|
||||||
|
writingItemAt: directory(in: root), options: [], error: &coordinationError
|
||||||
|
) { _ in
|
||||||
|
do { try body() } catch { thrown = error }
|
||||||
|
}
|
||||||
|
if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// The local notifications for an upload/download batch - one "Uploading 3
|
||||||
|
/// files…" when it starts, replaced in place by "Uploaded 3 files" when it
|
||||||
|
/// ends (both use the batch's id as the request identifier, so the second
|
||||||
|
/// *replaces* the first instead of stacking). Posted from whichever process
|
||||||
|
/// sees the event - the app, or the Share Extension while it's still alive.
|
||||||
|
/// iOS can't update a notification with live byte progress from a background
|
||||||
|
/// session, so start/finish is as much as a notification can say.
|
||||||
|
/// Compiled into both targets.
|
||||||
|
enum TransferNotifications {
|
||||||
|
static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) {
|
||||||
|
let verb = kind == .upload ? "Uploading" : "Downloading"
|
||||||
|
post(id: batch, body: "\(verb) \(files(count))…", sound: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func finished(_ batch: TransferBatch, id: String) {
|
||||||
|
let verb = batch.kind == .upload ? "upload" : "download"
|
||||||
|
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
|
||||||
|
let body: String
|
||||||
|
if batch.failed == 0 {
|
||||||
|
body = "\(past) \(files(batch.succeeded))"
|
||||||
|
} else if batch.succeeded == 0 {
|
||||||
|
body = "Couldn't \(verb) \(files(batch.failed))"
|
||||||
|
} else {
|
||||||
|
body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed"
|
||||||
|
}
|
||||||
|
post(id: id, body: body, sound: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func files(_ count: Int) -> String {
|
||||||
|
count == 1 ? "1 file" : "\(count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(id: String, body: String, sound: Bool) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body = body
|
||||||
|
if sound { content.sound = .default }
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counting one finished upload task - shared so the result is handled the
|
||||||
|
/// same way whether the Share Extension (still alive) or the app (relaunched
|
||||||
|
/// later) receives it.
|
||||||
|
enum UploadResults {
|
||||||
|
/// Deletes the task's staged copy, records its outcome, and - if it was the
|
||||||
|
/// last file of its batch - posts the summary notification and returns the
|
||||||
|
/// finished batch.
|
||||||
|
static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? {
|
||||||
|
guard let info = task.taskDescription
|
||||||
|
.flatMap({ $0.data(using: .utf8) })
|
||||||
|
.flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }),
|
||||||
|
info.kind == .upload
|
||||||
|
else { return nil }
|
||||||
|
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
|
||||||
|
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let ok = error == nil && (200..<300).contains(status)
|
||||||
|
let batch = TransferBatchStore.record(info, success: ok)
|
||||||
|
if let batch { TransferNotifications.finished(batch, id: info.batch) }
|
||||||
|
return (info, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate for the Share Extension's upload session while the extension is
|
||||||
|
/// still alive: files that finish before it closes are counted here, the
|
||||||
|
/// rest by the app (`TransferManager`) once the system relaunches it.
|
||||||
|
final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let result = UploadResults.handle(task, error: error) else { return }
|
||||||
|
// The app wasn't running to hear about this, so leave a note for it to
|
||||||
|
// refresh that folder next time it's active.
|
||||||
|
if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// What a background upload/download task is, stored in the task's
|
||||||
|
/// `taskDescription` so it's still known if the app was relaunched by the
|
||||||
|
/// system to deliver the result - possibly for a task the Share Extension
|
||||||
|
/// started. Compiled into both targets.
|
||||||
|
struct TransferTaskInfo: Codable {
|
||||||
|
enum Kind: String, Codable { case upload, download }
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
let batch: String
|
||||||
|
let name: String
|
||||||
|
let remoteFolder: String?
|
||||||
|
/// A file to delete once the task ends (the upload's staged copy).
|
||||||
|
let stagedPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running totals for one "upload these 3 files" / "download these 2 files"
|
||||||
|
/// request, so the one summary notification (and the Dart-side "folder
|
||||||
|
/// changed" event) fires exactly once, on the last file.
|
||||||
|
struct TransferBatch: Codable {
|
||||||
|
let kind: TransferTaskInfo.Kind
|
||||||
|
let remoteFolder: String?
|
||||||
|
let total: Int
|
||||||
|
var succeeded: Int
|
||||||
|
var failed: Int
|
||||||
|
|
||||||
|
var finished: Int { succeeded + failed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the
|
||||||
|
/// process's own: the Share Extension starts an upload batch, and the app -
|
||||||
|
/// a different process, possibly relaunched in the background - finishes
|
||||||
|
/// counting it.
|
||||||
|
enum TransferBatchStore {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
|
||||||
|
private static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: AppGroup.identifier) ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func key(_ id: String) -> String { "transfer.batch.\(id)" }
|
||||||
|
|
||||||
|
static func save(_ batch: TransferBatch, id: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let finishedFoldersKey = "transfer.finishedUploadFolders"
|
||||||
|
|
||||||
|
/// The Share Extension finished an upload into [folder] while the app
|
||||||
|
/// wasn't running; the app reads this on its next activation so its file
|
||||||
|
/// list can refresh.
|
||||||
|
static func noteFinishedUpload(folder: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
if !folders.contains(folder) { folders.append(folder) }
|
||||||
|
defaults.set(folders, forKey: finishedFoldersKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func consumeFinishedUploadFolders() -> [String] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
defaults.removeObject(forKey: finishedFoldersKey)
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one file's outcome. Returns the batch when that was its last
|
||||||
|
/// file (and forgets it), nil while others are still running.
|
||||||
|
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let storageKey = key(info.batch)
|
||||||
|
var batch =
|
||||||
|
defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }
|
||||||
|
?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0)
|
||||||
|
if success { batch.succeeded += 1 } else { batch.failed += 1 }
|
||||||
|
if batch.finished >= batch.total {
|
||||||
|
defaults.removeObject(forKey: storageKey)
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// URL building for a Nextcloud account's WebDAV files root. Pure functions
|
||||||
|
/// (no networking), so they're unit-tested in `RunnerTests`.
|
||||||
|
enum WebDAV {
|
||||||
|
/// `urlPathAllowed` minus "/", so a file name containing a slash-like
|
||||||
|
/// character can't introduce an extra path segment.
|
||||||
|
private static let segmentAllowed: CharacterSet = {
|
||||||
|
var set = CharacterSet.urlPathAllowed
|
||||||
|
set.remove(charactersIn: "/")
|
||||||
|
return set
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func encodeSegment(_ segment: String) -> String {
|
||||||
|
segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<server>/remote.php/dav/files/<user>/<remotePath>` - [remotePath] is
|
||||||
|
/// relative to the user's root, with or without a leading slash. A server
|
||||||
|
/// installed under a sub-path (`https://host/nextcloud`) keeps it.
|
||||||
|
static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? {
|
||||||
|
var base = serverUrl
|
||||||
|
while base.hasSuffix("/") { base.removeLast() }
|
||||||
|
let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) }
|
||||||
|
var url = base + "/remote.php/dav/files/" + encodeSegment(username)
|
||||||
|
if !segments.isEmpty { url += "/" + segments.joined(separator: "/") }
|
||||||
|
return URL(string: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `folder` + `name` with exactly one slash between them.
|
||||||
|
static func join(_ folder: String, _ name: String) -> String {
|
||||||
|
folder.hasSuffix("/") ? folder + name : folder + "/" + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocalFiles {
|
||||||
|
/// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then
|
||||||
|
/// "a (1).txt", "a (2).txt"... - so a second download of the same file
|
||||||
|
/// never overwrites the first.
|
||||||
|
static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL {
|
||||||
|
let candidate = directory.appendingPathComponent(name)
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) { return candidate }
|
||||||
|
let ext = (name as NSString).pathExtension
|
||||||
|
let stem = (name as NSString).deletingPathExtension
|
||||||
|
var index = 1
|
||||||
|
while true {
|
||||||
|
let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)"
|
||||||
|
let url = directory.appendingPathComponent(numbered)
|
||||||
|
if !fileManager.fileExists(atPath: url.path) { return url }
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -21,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';
|
||||||
@@ -35,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';
|
||||||
@@ -44,6 +46,8 @@ import 'widgets/shell/shell_common.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
|
||||||
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
// Split out of the former single `ServerProvider` god object - see
|
// Split out of the former single `ServerProvider` god object - see
|
||||||
@@ -55,7 +59,16 @@ 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; it picks its
|
||||||
|
// folders/uploads as whichever account was last published here.
|
||||||
|
session.addAccountReadyListener(
|
||||||
|
() => ShareAccountService.publish(session),
|
||||||
|
);
|
||||||
|
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
|
||||||
@@ -441,70 +454,73 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
scaffold = Scaffold(
|
scaffold = Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
body: Row(
|
// Tablets keep the system bars, so inset for them (desktop's
|
||||||
children: [
|
// insets are zero).
|
||||||
NooSidebar(
|
body: SafeArea(
|
||||||
account: NooSidebarAccount(
|
child: Row(
|
||||||
avatar: NooAvatar(
|
children: [
|
||||||
initials: accountInitial(session.username),
|
NooSidebar(
|
||||||
current: true,
|
account: NooSidebarAccount(
|
||||||
size: 32,
|
avatar: NooAvatar(
|
||||||
|
initials: accountInitial(session.username),
|
||||||
|
current: true,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
name: session.username,
|
||||||
|
subtitle: serverHost(session.serverUrl),
|
||||||
|
onTap: () => showAccountSwitcher(context),
|
||||||
),
|
),
|
||||||
name: session.username,
|
items: [
|
||||||
subtitle: serverHost(session.serverUrl),
|
for (final tab in pinnedTabs)
|
||||||
onTap: () => showAccountSwitcher(context),
|
NooSidebarItem(
|
||||||
),
|
icon: tab.icon,
|
||||||
items: [
|
label: tab.label,
|
||||||
for (final tab in pinnedTabs)
|
count: trashBadge(tab),
|
||||||
NooSidebarItem(
|
selected: tab == selectedTab,
|
||||||
icon: tab.icon,
|
onTap: () => selectTab(tab),
|
||||||
label: tab.label,
|
),
|
||||||
count: trashBadge(tab),
|
for (final tab in hiddenTabs)
|
||||||
selected: tab == selectedTab,
|
NooSidebarItem(
|
||||||
onTap: () => selectTab(tab),
|
icon: tab.icon,
|
||||||
),
|
label: tab.label,
|
||||||
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
|
count: trashBadge(tab),
|
||||||
for (final tab in hiddenTabs)
|
selected: tab == selectedTab,
|
||||||
NooSidebarItem(
|
onTap: () => selectTab(tab),
|
||||||
icon: tab.icon,
|
),
|
||||||
label: tab.label,
|
|
||||||
count: trashBadge(tab),
|
|
||||||
selected: tab == selectedTab,
|
|
||||||
onTap: () => selectTab(tab),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
storage: NooSidebarStorage(
|
|
||||||
value: quotaFraction(quota),
|
|
||||||
detail: quotaDetail(quota),
|
|
||||||
),
|
|
||||||
settings: NooSidebarItem(
|
|
||||||
icon: LucideIcons.settings,
|
|
||||||
label: 'Settings',
|
|
||||||
onTap: () => openSettings(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
NooToolbar(
|
|
||||||
title: selectedTab.label,
|
|
||||||
search: const ShellSearchLauncher(onSurface: true),
|
|
||||||
actions: [
|
|
||||||
if (canUpload)
|
|
||||||
NooButton(
|
|
||||||
icon: LucideIcons.upload,
|
|
||||||
onTap: () => showCreateMenu(context),
|
|
||||||
child: const Text('Upload'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
storage: NooSidebarStorage(
|
||||||
|
value: quotaFraction(quota),
|
||||||
|
detail: quotaDetail(quota),
|
||||||
|
),
|
||||||
|
settings: NooSidebarItem(
|
||||||
|
icon: LucideIcons.settings,
|
||||||
|
label: 'Settings',
|
||||||
|
onTap: () => openSettings(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
],
|
child: Column(
|
||||||
|
children: [
|
||||||
|
NooToolbar(
|
||||||
|
title: selectedTab.label,
|
||||||
|
search: const ShellSearchLauncher(onSurface: true),
|
||||||
|
actions: [
|
||||||
|
if (canUpload)
|
||||||
|
NooButton(
|
||||||
|
icon: LucideIcons.upload,
|
||||||
|
onTap: () => showCreateMenu(context),
|
||||||
|
child: const Text('Upload'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ColoredBox(color: colors.surface, child: tabStack),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -514,10 +530,18 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
drawerScrimColor: colors.scrim,
|
drawerScrimColor: colors.scrim,
|
||||||
// The top bar itself no longer lives here - see `tabStack` above.
|
// The top bar itself no longer lives here - see `tabStack` above.
|
||||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||||
|
// The avatar menu replaces the drawer, so the edge swipe must not
|
||||||
|
// open it either.
|
||||||
|
drawerEnableOpenDragGesture:
|
||||||
|
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,
|
||||||
@@ -526,7 +550,11 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
pickRequest == null && navStyle == NooNavStyle.android
|
pickRequest == null && navStyle == NooNavStyle.android
|
||||||
? NooFab(
|
? NooFab(
|
||||||
collapsed: !canUpload,
|
collapsed: switch (settings.fabStyle) {
|
||||||
|
FabStyle.auto => !canUpload,
|
||||||
|
FabStyle.mini => true,
|
||||||
|
FabStyle.expanded => false,
|
||||||
|
},
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
onTap: () => showCreateMenu(context),
|
onTap: () => showCreateMenu(context),
|
||||||
)
|
)
|
||||||
@@ -535,6 +563,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,
|
||||||
|
|||||||
@@ -39,4 +39,20 @@ class NextcloudShare {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bool get isFolder => itemType == NextcloudItemType.folder;
|
bool get isFolder => itemType == NextcloudItemType.folder;
|
||||||
|
|
||||||
|
NextcloudShare withPermissions(int permissions) => NextcloudShare(
|
||||||
|
id: id,
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
itemType: itemType,
|
||||||
|
shareType: shareType,
|
||||||
|
ownerDisplayName: ownerDisplayName,
|
||||||
|
sharedWithDisplayName: sharedWithDisplayName,
|
||||||
|
sharedAt: sharedAt,
|
||||||
|
sharedWithMe: sharedWithMe,
|
||||||
|
url: url,
|
||||||
|
permissions: permissions,
|
||||||
|
token: token,
|
||||||
|
expireDate: expireDate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
@@ -101,6 +108,7 @@ class FilesController extends ChangeNotifier
|
|||||||
NextcloudUserQuota? _quota;
|
NextcloudUserQuota? _quota;
|
||||||
List<NextcloudActivity> _activities = [];
|
List<NextcloudActivity> _activities = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _activating = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
FilesController(this.session) {
|
FilesController(this.session) {
|
||||||
@@ -134,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);
|
||||||
|
|
||||||
@@ -147,7 +155,10 @@ class FilesController extends ChangeNotifier
|
|||||||
CachePolicy get cachePolicy => _cachePolicy;
|
CachePolicy get cachePolicy => _cachePolicy;
|
||||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||||
@override
|
@override
|
||||||
bool get isLoading => _isLoading;
|
// Also true while a freshly activated account is still restoring prefs
|
||||||
|
// and while the session verifies it, so a switch shows a spinner instead
|
||||||
|
// of briefly flashing the empty-folder message.
|
||||||
|
bool get isLoading => _isLoading || _activating || session.isLoading;
|
||||||
@override
|
@override
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
NextcloudUserQuota? get quota => _quota;
|
NextcloudUserQuota? get quota => _quota;
|
||||||
@@ -184,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) {
|
||||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
case HiddenFilesFilter.hide:
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -227,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) {
|
||||||
@@ -266,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;
|
||||||
@@ -317,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) {
|
||||||
@@ -360,9 +381,16 @@ class FilesController extends ChangeNotifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAccountActivated() async {
|
Future<void> _onAccountActivated() async {
|
||||||
await _restoreDisplayPrefs();
|
_activating = true;
|
||||||
_startCacheRefreshTimerIfNeeded();
|
notifyListeners();
|
||||||
await refreshData();
|
try {
|
||||||
|
await _restoreDisplayPrefs();
|
||||||
|
_startCacheRefreshTimerIfNeeded();
|
||||||
|
await refreshData();
|
||||||
|
} finally {
|
||||||
|
_activating = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -628,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',
|
||||||
@@ -639,14 +669,35 @@ 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ class ItemOperations {
|
|||||||
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
||||||
);
|
);
|
||||||
final existingNames = needsKeepBoth
|
final existingNames = needsKeepBoth
|
||||||
? (await files.fetchFolderListing(destFolderPath))
|
? (await files.fetchFolderListing(
|
||||||
.map((i) => i.name)
|
destFolderPath,
|
||||||
.toSet()
|
)).map((i) => i.name).toSet()
|
||||||
: <String>{};
|
: <String>{};
|
||||||
|
|
||||||
var succeeded = 0;
|
var succeeded = 0;
|
||||||
@@ -303,6 +303,20 @@ class ItemOperations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> updateSharePermissions(
|
||||||
|
NextcloudShare share,
|
||||||
|
int permissions,
|
||||||
|
) async {
|
||||||
|
final service = session.service;
|
||||||
|
if (service == null) return false;
|
||||||
|
try {
|
||||||
|
return await service.updateSharePermissions(share.id, permissions);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ItemOperations] updateSharePermissions failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes a share (from the file-details/Share sheet, not the Shares
|
/// Deletes a share (from the file-details/Share sheet, not the Shares
|
||||||
/// tab itself - see `SharesController.deleteShare` for that one, which
|
/// tab itself - see `SharesController.deleteShare` for that one, which
|
||||||
/// additionally patches the tab's own list).
|
/// additionally patches the tab's own list).
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
import 'files_controller.dart';
|
import 'files_controller.dart';
|
||||||
@@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
|
_syncBasePath ??= (await SyncService.baseDirectory())?.path;
|
||||||
// A sync can delete the very folder being browsed (deleted on the
|
// A sync can delete the very folder being browsed (deleted on the
|
||||||
// server) - step out to the nearest ancestor that still exists rather
|
// server) - step out to the nearest ancestor that still exists rather
|
||||||
// than showing a phantom empty folder.
|
// than showing a phantom empty folder.
|
||||||
@@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
Future<String?> localPathFor(NextcloudItem item) async {
|
Future<String?> localPathFor(NextcloudItem item) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return null;
|
if (accountId == null) return null;
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = item.path.startsWith('/')
|
final relPath = item.path.startsWith('/')
|
||||||
? item.path.substring(1)
|
? item.path.substring(1)
|
||||||
@@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
String accountId,
|
String accountId,
|
||||||
String folderPath,
|
String folderPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return [];
|
if (base == null) return [];
|
||||||
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
||||||
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
|
|
||||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||||
|
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||||
static const _prefSortField = 'ui_sort_field';
|
static const _prefSortField = 'ui_sort_field';
|
||||||
static const _prefSortAscending = 'ui_sort_ascending';
|
static const _prefSortAscending = 'ui_sort_ascending';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
bool _showFavoritesOnly = false;
|
bool _showFavoritesOnly = false;
|
||||||
bool _showHidden = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
FileSortField _sortField = FileSortField.name;
|
FileSortField _sortField = FileSortField.name;
|
||||||
bool _sortAscending = true;
|
bool _sortAscending = true;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||||
bool get showHidden => _showHidden;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get sortField => _sortField;
|
FileSortField get sortField => _sortField;
|
||||||
bool get sortAscending => _sortAscending;
|
bool get sortAscending => _sortAscending;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
final filtered = files.applyCommonFilters(
|
final filtered = files.applyCommonFilters(
|
||||||
media,
|
media,
|
||||||
showFavoritesOnly: _showFavoritesOnly,
|
showFavoritesOnly: _showFavoritesOnly,
|
||||||
showHidden: _showHidden,
|
hidden: _hiddenFilter,
|
||||||
)..sort((a, b) => _compare(a, b));
|
)..sort((a, b) => _compare(a, b));
|
||||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||||
_showFavoritesOnly =
|
_showFavoritesOnly =
|
||||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
k(_prefHiddenFilterPhotos),
|
||||||
|
k(_prefShowHiddenPhotos),
|
||||||
|
);
|
||||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||||
_sortField = FileSortField.values.firstWhere(
|
_sortField = FileSortField.values.firstWhere(
|
||||||
(f) => f.name == sortFieldName,
|
(f) => f.name == sortFieldName,
|
||||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShowHidden() async {
|
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||||
if (!_showHidden) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHidden = !_showHidden;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenPhotos,
|
_prefHiddenFilterPhotos,
|
||||||
(p, key) => p.setBool(key, _showHidden),
|
(p, key) => p.setString(key, filter.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
|||||||
/// in Settings.
|
/// in Settings.
|
||||||
enum SwipeAction { none, favorite, delete, share }
|
enum SwipeAction { none, favorite, delete, share }
|
||||||
|
|
||||||
|
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
|
||||||
|
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
||||||
|
/// always shows the label.
|
||||||
|
enum FabStyle { auto, mini, expanded }
|
||||||
|
|
||||||
/// Thumb/track presets for the video player's seek bar, matching the four
|
/// Thumb/track presets for the video player's seek bar, matching the four
|
||||||
/// combinations offered by other Material You media players: a Material 3
|
/// combinations offered by other Material You media players: a Material 3
|
||||||
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
||||||
@@ -16,6 +21,30 @@ enum SwipeAction { none, favorite, delete, share }
|
|||||||
/// animated wave with a tick-mark thumb ([squiggly]).
|
/// animated wave with a tick-mark thumb ([squiggly]).
|
||||||
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
||||||
|
|
||||||
|
/// The frosted-glass bottom bar's blur sigma and surface opacity, in three
|
||||||
|
/// ready-made strengths (Settings, Appearance). Moving either slider off all
|
||||||
|
/// three leaves no preset selected (a custom mix).
|
||||||
|
enum FrostedGlassPreset {
|
||||||
|
less(blur: 10, opacity: 0.88, label: 'Less'),
|
||||||
|
standard(blur: 20, opacity: 0.72, label: 'Default'),
|
||||||
|
more(blur: 32, opacity: 0.55, label: 'More');
|
||||||
|
|
||||||
|
final double blur;
|
||||||
|
final double opacity;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const FrostedGlassPreset({
|
||||||
|
required this.blur,
|
||||||
|
required this.opacity,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const double minFrostedBlur = 0;
|
||||||
|
const double maxFrostedBlur = 40;
|
||||||
|
const double minFrostedOpacity = 0.3;
|
||||||
|
const double maxFrostedOpacity = 1.0;
|
||||||
|
|
||||||
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
|
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
|
||||||
/// configuration, swipe actions. Independent of login state - these mean
|
/// configuration, swipe actions. Independent of login state - these mean
|
||||||
/// the same thing whether any account is signed in or not, unlike
|
/// the same thing whether any account is signed in or not, unlike
|
||||||
@@ -31,7 +60,11 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefDefaultTab = 'ui_default_tab';
|
static const _prefDefaultTab = 'ui_default_tab';
|
||||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
|
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';
|
||||||
@@ -45,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;
|
||||||
@@ -61,6 +97,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
||||||
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
||||||
|
FabStyle _fabStyle = FabStyle.auto;
|
||||||
|
|
||||||
// A one-shot request for the shell to switch its active bottom-nav tab
|
// A one-shot request for the shell to switch its active bottom-nav tab
|
||||||
// (e.g. a search result landing on Files) - consumed and cleared by
|
// (e.g. a search result landing on Files) - consumed and cleared by
|
||||||
@@ -88,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;
|
||||||
@@ -121,6 +167,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
SwipeAction get swipeLeftAction => _swipeLeftAction;
|
SwipeAction get swipeLeftAction => _swipeLeftAction;
|
||||||
SwipeAction get swipeRightAction => _swipeRightAction;
|
SwipeAction get swipeRightAction => _swipeRightAction;
|
||||||
|
FabStyle get fabStyle => _fabStyle;
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
try {
|
try {
|
||||||
@@ -147,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 =
|
||||||
@@ -242,6 +300,14 @@ class SettingsController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final fabName = prefs.getString(_prefFabStyle);
|
||||||
|
if (fabName != null) {
|
||||||
|
_fabStyle = FabStyle.values.firstWhere(
|
||||||
|
(f) => f.name == fabName,
|
||||||
|
orElse: () => _fabStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[SettingsController] Preference restore failed: $e');
|
debugPrint('[SettingsController] Preference restore failed: $e');
|
||||||
@@ -297,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;
|
||||||
@@ -404,4 +498,11 @@ class SettingsController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
|
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setFabStyle(FabStyle style) {
|
||||||
|
if (_fabStyle == style) return;
|
||||||
|
_fabStyle = style;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefFabStyle, style.name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class DownloadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
|
await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
|
||||||
|
...args,
|
||||||
|
'files': filesJson,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
|
||||||
/// The "get this account's auth header, or bail" guard every native-
|
/// The "get this account's auth header, or bail" guard every native-
|
||||||
@@ -21,3 +22,57 @@ Map<String, String> baseChannelArgs(SessionController session) {
|
|||||||
'authHeader': requireAuthHeader(session),
|
'authHeader': requireAuthHeader(session),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thrown by user-triggered native services (upload, download, sync now...)
|
||||||
|
/// on a platform whose native side isn't implemented yet - the message is
|
||||||
|
/// shown to the user as-is, e.g. in a snackbar.
|
||||||
|
class NativeServiceUnavailable implements Exception {
|
||||||
|
final String feature;
|
||||||
|
|
||||||
|
const NativeServiceUnavailable(this.feature);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "$feature isn't available on this platform yet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls [method], treating a missing native implementation (no
|
||||||
|
/// `MethodChannel` handler registered, as on iOS until each service is
|
||||||
|
/// built there) as "nothing to report": returns null instead of throwing
|
||||||
|
/// `MissingPluginException`. For cold-start checks, scheduling and other
|
||||||
|
/// calls nobody is waiting on.
|
||||||
|
Future<T?> invokeIfAvailable<T>(
|
||||||
|
MethodChannel channel,
|
||||||
|
String method, [
|
||||||
|
Object? arguments,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
return await channel.invokeMethod<T>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [invokeIfAvailable], but for an action the user just asked for:
|
||||||
|
/// throws [NativeServiceUnavailable] naming [feature] rather than silently
|
||||||
|
/// doing nothing.
|
||||||
|
Future<T?> invokeOrExplain<T>(
|
||||||
|
MethodChannel channel,
|
||||||
|
String method,
|
||||||
|
String feature, [
|
||||||
|
Object? arguments,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
return await channel.invokeMethod<T>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
throw NativeServiceUnavailable(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [channel]'s broadcast stream mapped through [convert], with the
|
||||||
|
/// "no native implementation" error dropped - the stream just never emits.
|
||||||
|
Stream<T> quietEvents<T>(EventChannel channel, T Function(dynamic) convert) {
|
||||||
|
return channel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.handleError((Object _) {}, test: (e) => e is MissingPluginException)
|
||||||
|
.map(convert);
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,6 +147,19 @@ class NextcloudService {
|
|||||||
return response.bodyBytes;
|
return response.bodyBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by
|
||||||
|
/// the in-app text editor. Returns whether the server accepted it.
|
||||||
|
Future<bool> putBytes(String itemPath, List<int> bytes) async {
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse(fileUrl(itemPath)),
|
||||||
|
headers: _headers,
|
||||||
|
body: bytes,
|
||||||
|
);
|
||||||
|
return response.statusCode == 200 ||
|
||||||
|
response.statusCode == 201 ||
|
||||||
|
response.statusCode == 204;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> testConnection() async {
|
Future<bool> testConnection() async {
|
||||||
try {
|
try {
|
||||||
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
|
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
|
||||||
@@ -1525,6 +1538,22 @@ class NextcloudService {
|
|||||||
return response.statusCode == 200;
|
return response.statusCode == 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Changes a share's permission bitmask (1 read, 2 update, 4 create,
|
||||||
|
/// 8 delete, 16 reshare).
|
||||||
|
Future<bool> updateSharePermissions(String shareId, int permissions) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
|
||||||
|
debugPrint('[Nextcloud OCS] Updating share $shareId permissions');
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: _headers,
|
||||||
|
body: {'permissions': '$permissions'},
|
||||||
|
);
|
||||||
|
debugPrint('[Nextcloud OCS] Update share status: ${response.statusCode}');
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
|
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
|
||||||
final path = (raw['path'] ?? '/').toString();
|
final path = (raw['path'] ?? '/').toString();
|
||||||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../models/pick_request.dart';
|
import '../models/pick_request.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
||||||
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
||||||
@@ -14,7 +15,8 @@ class PickIntentService {
|
|||||||
/// Non-null if the app was launched cold as another app's file/photo
|
/// Non-null if the app was launched cold as another app's file/photo
|
||||||
/// picker.
|
/// picker.
|
||||||
static Future<PickRequest?> getPickRequest() async {
|
static Future<PickRequest?> getPickRequest() async {
|
||||||
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getPickRequest',
|
'getPickRequest',
|
||||||
);
|
);
|
||||||
return result == null ? null : PickRequest.fromMap(result);
|
return result == null ? null : PickRequest.fromMap(result);
|
||||||
@@ -23,7 +25,8 @@ class PickIntentService {
|
|||||||
/// Emits whenever the app is asked to act as a picker while already
|
/// Emits whenever the app is asked to act as a picker while already
|
||||||
/// running.
|
/// running.
|
||||||
static Stream<PickRequest> get onNewPickRequest {
|
static Stream<PickRequest> get onNewPickRequest {
|
||||||
return _newPickChannel.receiveBroadcastStream().map(
|
return quietEvents(
|
||||||
|
_newPickChannel,
|
||||||
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ class PickIntentService {
|
|||||||
List<String> localPaths,
|
List<String> localPaths,
|
||||||
List<String> mimeTypes,
|
List<String> mimeTypes,
|
||||||
) {
|
) {
|
||||||
return _methodChannel.invokeMethod('finishPick', {
|
return invokeIfAvailable(_methodChannel, 'finishPick', {
|
||||||
'paths': localPaths,
|
'paths': localPaths,
|
||||||
'mimeTypes': mimeTypes,
|
'mimeTypes': mimeTypes,
|
||||||
});
|
});
|
||||||
@@ -42,6 +45,6 @@ class PickIntentService {
|
|||||||
|
|
||||||
/// Backs out of picking mode with no result, closing the picker.
|
/// Backs out of picking mode with no result, closing the picker.
|
||||||
static Future<void> cancelPick() {
|
static Future<void> cancelPick() {
|
||||||
return _methodChannel.invokeMethod('cancelPick');
|
return invokeIfAvailable(_methodChannel, 'cancelPick');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../providers/session_controller.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
|
/// Tells the native side which account is active, so iOS's Share Extension -
|
||||||
|
/// a separate process with no Flutter engine - can list folders and upload as
|
||||||
|
/// it 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 both calls are no-ops.
|
||||||
|
class ShareAccountService {
|
||||||
|
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
||||||
|
|
||||||
|
/// Publishes the active account (call whenever one becomes active).
|
||||||
|
static Future<void> publish(SessionController session) async {
|
||||||
|
final authHeader = session.service?.authHeaders['Authorization'];
|
||||||
|
if (authHeader == null || session.serverUrl.isEmpty) return;
|
||||||
|
final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl;
|
||||||
|
await invokeIfAvailable(_channel, 'setAccount', {
|
||||||
|
'serverUrl': session.serverUrl,
|
||||||
|
'username': session.username,
|
||||||
|
'authHeader': authHeader,
|
||||||
|
'displayName': '${session.username}@$host',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets it (sign-out / switching away) so the extension stops offering
|
||||||
|
/// uploads to an account that's no longer signed in.
|
||||||
|
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
||||||
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
||||||
@@ -40,7 +41,8 @@ class ShareIntentService {
|
|||||||
/// Whatever was shared to launch the app cold (empty if it was launched
|
/// Whatever was shared to launch the app cold (empty if it was launched
|
||||||
/// normally, not via a share).
|
/// normally, not via a share).
|
||||||
static Future<List<SharedFileRef>> getInitialShare() async {
|
static Future<List<SharedFileRef>> getInitialShare() async {
|
||||||
final result = await _methodChannel.invokeMethod<List<dynamic>>(
|
final result = await invokeIfAvailable<List<dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getInitialShare',
|
'getInitialShare',
|
||||||
);
|
);
|
||||||
return (result ?? [])
|
return (result ?? [])
|
||||||
@@ -51,7 +53,7 @@ class ShareIntentService {
|
|||||||
|
|
||||||
/// Emits whenever another share arrives while the app is already running.
|
/// Emits whenever another share arrives while the app is already running.
|
||||||
static Stream<List<SharedFileRef>> get onNewShare {
|
static Stream<List<SharedFileRef>> get onNewShare {
|
||||||
return _newShareChannel.receiveBroadcastStream().map((event) {
|
return quietEvents(_newShareChannel, (event) {
|
||||||
return (event as List<dynamic>)
|
return (event as List<dynamic>)
|
||||||
.cast<Map<dynamic, dynamic>>()
|
.cast<Map<dynamic, dynamic>>()
|
||||||
.map(SharedFileRef.fromMap)
|
.map(SharedFileRef.fromMap)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../models/saved_account.dart';
|
|||||||
import '../providers/files_controller.dart';
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
import '../providers/sync_status_controller.dart';
|
import '../providers/sync_status_controller.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
||||||
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
||||||
@@ -110,7 +111,7 @@ class SyncService {
|
|||||||
await cancelAccount(account.id);
|
await cancelAccount(account.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _channel.invokeMethod('reschedule', {
|
await invokeIfAvailable(_channel, 'reschedule', {
|
||||||
'accountId': account.id,
|
'accountId': account.id,
|
||||||
'serverUrl': account.serverUrl,
|
'serverUrl': account.serverUrl,
|
||||||
'username': account.username,
|
'username': account.username,
|
||||||
@@ -132,7 +133,7 @@ class SyncService {
|
|||||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||||
/// nothing left to sync).
|
/// nothing left to sync).
|
||||||
static Future<void> cancelAccount(String accountId) async {
|
static Future<void> cancelAccount(String accountId) async {
|
||||||
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
@@ -158,7 +159,7 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
||||||
await _channel.invokeMethod('syncNow', {
|
await invokeOrExplain(_channel, 'syncNow', 'Syncing', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -178,7 +179,8 @@ class SyncService {
|
|||||||
/// app start - the native bus only knows an account once a sync pass has
|
/// app start - the native bus only knows an account once a sync pass has
|
||||||
/// run in this process.
|
/// run in this process.
|
||||||
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_channel,
|
||||||
'getSyncStatus',
|
'getSyncStatus',
|
||||||
{'accountId': accountId},
|
{'accountId': accountId},
|
||||||
);
|
);
|
||||||
@@ -196,11 +198,10 @@ 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>),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
||||||
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
||||||
@@ -213,7 +214,7 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
final authHeader = session.service?.authHeaders['Authorization'];
|
||||||
if (authHeader == null) throw Exception('Not logged in.');
|
if (authHeader == null) throw Exception('Not logged in.');
|
||||||
await _channel.invokeMethod('resolveConflict', {
|
await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', {
|
||||||
'accountId': conflict.accountId,
|
'accountId': conflict.accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -236,12 +237,23 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return;
|
if (accountId == null) return;
|
||||||
await _channel.invokeMethod('removeLocalSync', {
|
await invokeIfAvailable(_channel, 'removeLocalSync', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'path': path,
|
'path': path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the `sync/<accountId>/...` mirror lives: Android's app-specific
|
||||||
|
/// external files dir (what `SyncEngine.kt` writes to); everywhere else -
|
||||||
|
/// iOS has no external storage, `getExternalStorageDirectory` throws
|
||||||
|
/// there - the app's private support directory.
|
||||||
|
static Future<Directory?> baseDirectory() async {
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
return getExternalStorageDirectory();
|
||||||
|
}
|
||||||
|
return getApplicationSupportDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
/// The deterministic local mirror path for [remoteItemPath] under
|
/// The deterministic local mirror path for [remoteItemPath] under
|
||||||
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
||||||
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
||||||
@@ -251,7 +263,7 @@ class SyncService {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String remoteItemPath,
|
String remoteItemPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = remoteItemPath.startsWith('/')
|
final relPath = remoteItemPath.startsWith('/')
|
||||||
? remoteItemPath.substring(1)
|
? remoteItemPath.substring(1)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class UploadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startUpload', {
|
await invokeOrExplain(_channel, 'startUpload', 'Uploading', {
|
||||||
...args,
|
...args,
|
||||||
'files': filesJson,
|
'files': filesJson,
|
||||||
'remoteFolder': files.currentFolderPath,
|
'remoteFolder': files.currentFolderPath,
|
||||||
@@ -70,7 +70,8 @@ class UploadService {
|
|||||||
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
||||||
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
||||||
/// subscription per call, and the native side only keeps the latest one.
|
/// subscription per call, and the native side only keeps the latest one.
|
||||||
static final Stream<UploadCompletion> completions = _statusChannel
|
static final Stream<UploadCompletion> completions = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
|
(event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
|||||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||||
|
///
|
||||||
|
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||||
|
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||||
|
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||||
|
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||||
|
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||||
|
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||||
|
/// off the page.
|
||||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
final dark = cs.brightness == Brightness.dark;
|
||||||
|
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||||
return NooColors(
|
return NooColors(
|
||||||
bg: cs.surface,
|
bg: cs.surface,
|
||||||
surface: cs.surfaceContainerLow,
|
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||||
surface2: cs.surfaceContainerHigh,
|
surface2: cs.surfaceContainerHigh,
|
||||||
surface3: cs.surfaceContainerHighest,
|
surface3: cs.surfaceContainerHighest,
|
||||||
line: cs.outlineVariant,
|
line: cs.outlineVariant,
|
||||||
|
|||||||
@@ -20,11 +20,8 @@ import '../widgets/settings/settings_tabs.dart';
|
|||||||
/// order: account card, accounts, security, file sync, files cache,
|
/// order: account card, accounts, security, file sync, files cache,
|
||||||
/// appearance, tabs, action bar, swipe on a file).
|
/// appearance, tabs, action bar, swipe on a file).
|
||||||
///
|
///
|
||||||
/// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide
|
/// Every layout (including wide tablet, in a centered 640px column) is
|
||||||
/// enough to see every section at once, so it has no scroll-depth problem
|
/// a two-level menu, the way native iOS/Android Settings apps
|
||||||
/// and needs no menu.
|
|
||||||
///
|
|
||||||
/// Mobile is a two-level menu, the way native iOS/Android Settings apps
|
|
||||||
/// work: [SettingsAccountCard] (the account summary, not a settings picker)
|
/// work: [SettingsAccountCard] (the account summary, not a settings picker)
|
||||||
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and
|
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and
|
||||||
/// every other section becomes one tappable [NooSettingsRow] - icon, title,
|
/// every other section becomes one tappable [NooSettingsRow] - icon, title,
|
||||||
@@ -47,18 +44,6 @@ import '../widgets/settings/settings_tabs.dart';
|
|||||||
class AccountView extends StatelessWidget {
|
class AccountView extends StatelessWidget {
|
||||||
const AccountView({super.key});
|
const AccountView({super.key});
|
||||||
|
|
||||||
static const _sections = <Widget>[
|
|
||||||
SettingsAccountCard(),
|
|
||||||
SettingsAccountsSection(),
|
|
||||||
SettingsSecuritySection(),
|
|
||||||
SettingsFileSyncSection(),
|
|
||||||
SettingsFilesCacheSection(),
|
|
||||||
SettingsAppearanceSection(),
|
|
||||||
SettingsTabsSection(),
|
|
||||||
SettingsActionBarSection(),
|
|
||||||
SettingsSwipeSection(),
|
|
||||||
];
|
|
||||||
|
|
||||||
static final _categories = <_SettingsCategory>[
|
static final _categories = <_SettingsCategory>[
|
||||||
_SettingsCategory(
|
_SettingsCategory(
|
||||||
title: 'Accounts',
|
title: 'Accounts',
|
||||||
@@ -110,7 +95,7 @@ class AccountView extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
appBar: desktop
|
appBar: desktop
|
||||||
? const NooToolbar(title: 'Settings')
|
? _desktopBar(context, 'Settings')
|
||||||
: NooTopBar(
|
: NooTopBar(
|
||||||
style: NooLayout.navStyle(context),
|
style: NooLayout.navStyle(context),
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -119,7 +104,12 @@ class AccountView extends StatelessWidget {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: desktop
|
child: desktop
|
||||||
? _DesktopGrid(sections: _sections)
|
? Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
|
child: _MobileMenu(categories: _categories),
|
||||||
|
),
|
||||||
|
)
|
||||||
: _MobileMenu(categories: _categories),
|
: _MobileMenu(categories: _categories),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -207,78 +197,61 @@ class _SettingsCategoryScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
appBar: NooTopBar(
|
appBar: desktop
|
||||||
style: NooLayout.navStyle(context),
|
? _desktopBar(context, category.title)
|
||||||
title: category.title,
|
: NooTopBar(
|
||||||
leading: const NooTopBarBack(),
|
style: NooLayout.navStyle(context),
|
||||||
),
|
title: category.title,
|
||||||
|
leading: const NooTopBarBack(),
|
||||||
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: ListView(
|
child: Center(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
child: ConstrainedBox(
|
||||||
NooSpace.sm,
|
constraints: BoxConstraints(
|
||||||
NooSpace.sm,
|
maxWidth: desktop ? 640 : double.infinity,
|
||||||
NooSpace.sm,
|
),
|
||||||
NooSpace.xxl,
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xxl,
|
||||||
|
),
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
children: [category.builder(context)],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
children: [category.builder(context)],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits the sections between two columns rather than a strict grid, since
|
PreferredSizeWidget _desktopBar(BuildContext context, String title) {
|
||||||
/// each card's content height varies a lot (the tab reorder list and the
|
final top = MediaQuery.paddingOf(context).top;
|
||||||
/// accounts list can both run much taller than, say, Security) - a fixed
|
return PreferredSize(
|
||||||
/// grid would either clip content or leave large gaps.
|
preferredSize: Size.fromHeight(NooToolbar.outerHeight + top),
|
||||||
class _DesktopGrid extends StatelessWidget {
|
child: Padding(
|
||||||
final List<Widget> sections;
|
padding: EdgeInsets.only(top: top),
|
||||||
const _DesktopGrid({required this.sections});
|
child: NooToolbar(
|
||||||
|
titleWidget: Row(
|
||||||
@override
|
mainAxisSize: MainAxisSize.min,
|
||||||
Widget build(BuildContext context) {
|
spacing: NooSpace.sm,
|
||||||
final left = <Widget>[];
|
|
||||||
final right = <Widget>[];
|
|
||||||
for (var i = 0; i < sections.length; i++) {
|
|
||||||
(i.isEven ? left : right).add(sections[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(NooSpace.xl),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 1040),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _Column(children: left)),
|
IconButton(
|
||||||
const SizedBox(width: NooSpace.xl),
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
Expanded(child: _Column(children: right)),
|
tooltip: 'Back',
|
||||||
|
onPressed: () => Navigator.maybePop(context),
|
||||||
|
),
|
||||||
|
Text(title),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
|
||||||
|
|
||||||
class _Column extends StatelessWidget {
|
|
||||||
final List<Widget> children;
|
|
||||||
const _Column({required this.children});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
for (final child in children) ...[
|
|
||||||
child,
|
|
||||||
const SizedBox(height: NooSpace.xl),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -586,7 +586,9 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
),
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: isDesktop ? 5 : 2,
|
crossAxisCount: isDesktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||||
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../widgets/noo/overlays/noo_overlay_header.dart';
|
|||||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/viewer/media_action_bar.dart';
|
import '../widgets/viewer/media_action_bar.dart';
|
||||||
|
import '../widgets/viewer/media_details_panel.dart';
|
||||||
import '../widgets/viewer/media_image_preview.dart';
|
import '../widgets/viewer/media_image_preview.dart';
|
||||||
import '../widgets/viewer/media_pdf_preview.dart';
|
import '../widgets/viewer/media_pdf_preview.dart';
|
||||||
import '../widgets/viewer/media_text_preview.dart';
|
import '../widgets/viewer/media_text_preview.dart';
|
||||||
@@ -154,6 +155,43 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
/// also drag the gallery to the next item.
|
/// also drag the gallery to the next item.
|
||||||
bool _isZoomed = false;
|
bool _isZoomed = false;
|
||||||
|
|
||||||
|
final Map<int, Offset> _pointerStart = {};
|
||||||
|
bool _multiTouch = false;
|
||||||
|
|
||||||
|
void _onPointerDown(PointerDownEvent e) {
|
||||||
|
if (_pointerStart.isNotEmpty) _multiTouch = true;
|
||||||
|
_pointerStart[e.pointer] = e.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw pointer events rather than a drag recognizer, so this never competes
|
||||||
|
// with the PageView / InteractiveViewer gestures in the arena.
|
||||||
|
void _onPointerUp(PointerUpEvent e) {
|
||||||
|
final start = _pointerStart.remove(e.pointer);
|
||||||
|
final wasMulti = _multiTouch;
|
||||||
|
if (_pointerStart.isEmpty) _multiTouch = false;
|
||||||
|
if (start == null || wasMulti || _isZoomed || !_isSwipeable) return;
|
||||||
|
final delta = e.position - start;
|
||||||
|
if (delta.dy < -80 && delta.dy.abs() > delta.dx.abs() * 1.5) {
|
||||||
|
_openDetails();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
||||||
|
|
||||||
|
void _openDetails() {
|
||||||
|
if (NooLayout.isDesktop(context)) {
|
||||||
|
DetailsSheet.show(context, _currentItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _controlsVisible = true);
|
||||||
|
_panelKey.currentState?.expand();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerCancel(PointerCancelEvent e) {
|
||||||
|
_pointerStart.remove(e.pointer);
|
||||||
|
if (_pointerStart.isEmpty) _multiTouch = false;
|
||||||
|
}
|
||||||
|
|
||||||
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
|
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
|
||||||
|
|
||||||
bool get _isOffline => widget.localPathResolver != null;
|
bool get _isOffline => widget.localPathResolver != null;
|
||||||
@@ -394,13 +432,25 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
backgroundColor: stageColor,
|
backgroundColor: stageColor,
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => setState(() => _controlsVisible = !_controlsVisible),
|
onTap: () {
|
||||||
|
final panel = _panelKey.currentState;
|
||||||
|
if (panel != null && panel.isExpanded) {
|
||||||
|
panel.collapse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _controlsVisible = !_controlsVisible);
|
||||||
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Listener(
|
||||||
color: stageColor,
|
onPointerDown: _onPointerDown,
|
||||||
child: _buildBody(context, session),
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerCancel: _onPointerCancel,
|
||||||
|
child: Container(
|
||||||
|
color: stageColor,
|
||||||
|
child: _buildBody(context, session),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_downloadProgress != null)
|
if (_downloadProgress != null)
|
||||||
@@ -522,25 +572,35 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_controlsVisible,
|
ignoring: !_controlsVisible,
|
||||||
child: MediaActionBar(
|
child: MediaDetailsPanel(
|
||||||
isFavorite: _currentItem.isFavorite,
|
key: _panelKey,
|
||||||
isBusy: _isBusy,
|
item: _currentItem,
|
||||||
// Favorite/delete/download-to-device all need a live
|
builder: (context, handle, below) => MediaActionBar(
|
||||||
// server - hidden rather than shown-and-failing while
|
onDetails: NooLayout.isDesktop(context)
|
||||||
// browsing an already-local file from the Offline tab.
|
? _openDetails
|
||||||
showServerActions: !_isOffline,
|
: null,
|
||||||
transportControls:
|
handle: NooLayout.isDesktop(context) ? null : handle,
|
||||||
_isSwipeable &&
|
below: NooLayout.isDesktop(context) ? null : below,
|
||||||
_currentItem.type == NextcloudItemType.video &&
|
isFavorite: _currentItem.isFavorite,
|
||||||
_videoController != null
|
isBusy: _isBusy,
|
||||||
? VideoTransportControls(controller: _videoController!)
|
// Favorite/delete/download-to-device all need a live
|
||||||
: null,
|
// server - hidden rather than shown-and-failing while
|
||||||
onShare: () => ShareSheet.show(context, _currentItem),
|
// browsing an already-local file from the Offline tab.
|
||||||
onFavorite: () => _toggleFavorite(ops),
|
showServerActions: !_isOffline,
|
||||||
onDelete: () => _deleteCurrentItem(ops),
|
transportControls:
|
||||||
onOpenExternally: () => _openExternally(session),
|
_isSwipeable &&
|
||||||
onDownload: () => _downloadToDevice(session),
|
_currentItem.type == NextcloudItemType.video &&
|
||||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
_videoController != null
|
||||||
|
? VideoTransportControls(
|
||||||
|
controller: _videoController!,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onShare: () => ShareSheet.show(context, _currentItem),
|
||||||
|
onFavorite: () => _toggleFavorite(ops),
|
||||||
|
onDelete: () => _deleteCurrentItem(ops),
|
||||||
|
onOpenExternally: () => _openExternally(session),
|
||||||
|
onDownload: () => _downloadToDevice(session),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -659,6 +719,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
default:
|
default:
|
||||||
if (_isPdf) {
|
if (_isPdf) {
|
||||||
return MediaPdfPreview(
|
return MediaPdfPreview(
|
||||||
|
topInset: _controlsVisible
|
||||||
|
? MediaQuery.paddingOf(context).top + 60
|
||||||
|
: 0,
|
||||||
item: widget.item,
|
item: widget.item,
|
||||||
session: session,
|
session: session,
|
||||||
localPathResolver: widget.localPathResolver,
|
localPathResolver: widget.localPathResolver,
|
||||||
|
|||||||
@@ -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
|
///
|
||||||
? context.watch<OfflineController>()
|
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||||
: context.watch<FilesController>();
|
/// of `build` asserts in debug builds (silently swallowing the tap).
|
||||||
|
FolderBrowser _browserOf(BuildContext context, {bool listen = true}) {
|
||||||
|
if (listen) {
|
||||||
|
return _offline
|
||||||
|
? context.watch<OfflineController>()
|
||||||
|
: context.watch<FilesController>();
|
||||||
|
}
|
||||||
|
return _offline
|
||||||
|
? context.read<OfflineController>()
|
||||||
|
: context.read<FilesController>();
|
||||||
|
}
|
||||||
|
|
||||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
/// 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,92 +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 : 2,
|
|
||||||
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)),
|
||||||
),
|
),
|
||||||
@@ -1323,6 +1437,9 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
|
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
|
||||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||||
|
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
|
? null
|
||||||
|
: () => _toggleSelection(item),
|
||||||
onMore: !_isSelecting && !picking
|
onMore: !_isSelecting && !picking
|
||||||
? () => _showItemActions(context, item)
|
? () => _showItemActions(context, item)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -284,7 +284,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: desktop ? 5 : 2,
|
crossAxisCount: desktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: desktop ? 16 : 10,
|
crossAxisSpacing: desktop ? 16 : 10,
|
||||||
mainAxisSpacing: desktop ? 16 : 10,
|
mainAxisSpacing: desktop ? 16 : 10,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -342,7 +343,11 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
itemCount: group.items.length,
|
itemCount: group.items.length,
|
||||||
columns: isDesktop
|
columns: isDesktop
|
||||||
? NooPhotoGrid.desktopColumns
|
? NooPhotoGrid.desktopColumns
|
||||||
: NooPhotoGrid.mobileColumns,
|
: NooLayout.gridColumns(
|
||||||
|
context,
|
||||||
|
phone: NooPhotoGrid.mobileColumns,
|
||||||
|
minTile: 130,
|
||||||
|
),
|
||||||
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
||||||
@@ -400,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
|
||||||
@@ -517,32 +522,36 @@ 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(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
value: _typeFilter,
|
fill: true,
|
||||||
onChanged: (filter) => setState(() {
|
onSurface: true,
|
||||||
_typeFilter = filter;
|
labelOnlySelected: true,
|
||||||
setSheetState(() {});
|
value: _typeFilter,
|
||||||
}),
|
onChanged: (filter) => setState(() {
|
||||||
options: const [
|
_typeFilter = filter;
|
||||||
NooSegmentOption(
|
setSheetState(() {});
|
||||||
value: _PhotoTypeFilter.all,
|
}),
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: _PhotoTypeFilter.image,
|
label: 'All',
|
||||||
icon: LucideIcons.image,
|
),
|
||||||
label: 'Photos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.image,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.image,
|
||||||
value: _PhotoTypeFilter.video,
|
label: 'Photos',
|
||||||
icon: LucideIcons.film,
|
),
|
||||||
label: 'Videos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.video,
|
||||||
],
|
icon: LucideIcons.film,
|
||||||
|
label: 'Videos',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
NooGroupedList(
|
||||||
@@ -555,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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: desktop ? 5 : 2,
|
crossAxisCount: desktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: desktop ? 16 : 10,
|
crossAxisSpacing: desktop ? 16 : 10,
|
||||||
mainAxisSpacing: desktop ? 16 : 10,
|
mainAxisSpacing: desktop ? 16 : 10,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../providers/settings_controller.dart';
|
|||||||
import '../providers/trash_controller.dart';
|
import '../providers/trash_controller.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'noo/core/noo_avatar.dart';
|
import 'noo/core/noo_avatar.dart';
|
||||||
|
import '../views/login_view.dart';
|
||||||
import 'noo/core/noo_badge.dart';
|
import 'noo/core/noo_badge.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
@@ -43,9 +44,16 @@ Future<void> showAvatarMenu(BuildContext context) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarMenuContent extends StatelessWidget {
|
class _AvatarMenuContent extends StatefulWidget {
|
||||||
const _AvatarMenuContent();
|
const _AvatarMenuContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
@@ -124,7 +132,7 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
// header reads as a continuation of the button that
|
// header reads as a continuation of the button that
|
||||||
// opened it rather than a disconnected card.
|
// opened it rather than a disconnected card.
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: closeAndOpenSettings,
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(NooSpace.md),
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -161,10 +169,87 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
current: true,
|
current: true,
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colors.fg1.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_expanded
|
||||||
|
? LucideIcons.chevronUp
|
||||||
|
: LucideIcons.chevronDown,
|
||||||
|
size: 20,
|
||||||
|
color: colors.fg2,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: NooMotion.fast,
|
||||||
|
curve: NooMotion.ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _expanded
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
for (final account in session.accounts)
|
||||||
|
if (account.id != session.activeAccountId)
|
||||||
|
_OtherAccountRow(
|
||||||
|
name: account.username,
|
||||||
|
host: serverHost(account.serverUrl),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
session.switchAccount(account.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.md,
|
||||||
|
0,
|
||||||
|
NooSpace.md,
|
||||||
|
NooSpace.md,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _AccountButton(
|
||||||
|
icon: LucideIcons.userPlus,
|
||||||
|
label: 'Add Account',
|
||||||
|
onTap: () {
|
||||||
|
final nav = Navigator.of(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
nav.pop();
|
||||||
|
nav.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const LoginView(
|
||||||
|
isAddingAccount: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _AccountButton(
|
||||||
|
icon: LucideIcons.users,
|
||||||
|
label: 'Manage Accounts',
|
||||||
|
onTap: closeAndOpenSettings,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
if (hiddenTabs.isNotEmpty) ...[
|
if (hiddenTabs.isNotEmpty) ...[
|
||||||
Divider(height: 1, color: colors.line),
|
Divider(height: 1, color: colors.line),
|
||||||
for (final tab in hiddenTabs)
|
for (final tab in hiddenTabs)
|
||||||
@@ -201,3 +286,100 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OtherAccountRow extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String host;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _OtherAccountRow({
|
||||||
|
required this.name,
|
||||||
|
required this.host,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.bodyL.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
host,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
||||||
|
const SizedBox(width: 44),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _AccountButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final radius = BorderRadius.circular(NooRadii.input);
|
||||||
|
return Material(
|
||||||
|
color: colors.bg,
|
||||||
|
borderRadius: radius,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: radius,
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: colors.fg2),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ enum _DetailsTab { info, versions, activity }
|
|||||||
/// and the "open externally" flow for unsupported file types.
|
/// and the "open externally" flow for unsupported file types.
|
||||||
///
|
///
|
||||||
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
|
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
|
||||||
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog],
|
/// desktop path in [show] passes [DetailsBody] straight to [showNooDialog],
|
||||||
/// which renders the header itself via `leading`/`title`/`subtitle`.
|
/// which renders the header itself via `leading`/`title`/`subtitle`.
|
||||||
class DetailsSheet extends StatelessWidget {
|
class DetailsSheet extends StatelessWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
@@ -68,7 +68,7 @@ class DetailsSheet extends StatelessWidget {
|
|||||||
leading: detailsFileTile(item),
|
leading: detailsFileTile(item),
|
||||||
title: item.name,
|
title: item.name,
|
||||||
subtitle: detailsMetaLine(item),
|
subtitle: detailsMetaLine(item),
|
||||||
children: [_DetailsBody(item: item)],
|
children: [DetailsBody(item: item)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
||||||
@@ -86,7 +86,7 @@ class DetailsSheet extends StatelessWidget {
|
|||||||
onClose: () => Navigator.pop(context),
|
onClose: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: NooSpace.lg),
|
const SizedBox(height: NooSpace.lg),
|
||||||
_DetailsBody(item: item),
|
DetailsBody(item: item),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,16 +102,16 @@ class DetailsSheet extends StatelessWidget {
|
|||||||
/// guard), so switching tabs via a bare `switch` on the selected type would
|
/// guard), so switching tabs via a bare `switch` on the selected type would
|
||||||
/// tear down and rebuild whichever tab isn't showing, discarding its
|
/// tear down and rebuild whichever tab isn't showing, discarding its
|
||||||
/// fetched data and re-requesting it every time the user switched back.
|
/// fetched data and re-requesting it every time the user switched back.
|
||||||
class _DetailsBody extends StatefulWidget {
|
class DetailsBody extends StatefulWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
|
|
||||||
const _DetailsBody({required this.item});
|
const DetailsBody({super.key, required this.item});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_DetailsBody> createState() => _DetailsBodyState();
|
State<DetailsBody> createState() => _DetailsBodyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DetailsBodyState extends State<_DetailsBody> {
|
class _DetailsBodyState extends State<DetailsBody> {
|
||||||
_DetailsTab _tab = _DetailsTab.info;
|
_DetailsTab _tab = _DetailsTab.info;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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,54 +171,46 @@ 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(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<FilesTypeFilter>(
|
||||||
value: files.filesTypeFilter,
|
fill: true,
|
||||||
onChanged: files.setFilesTypeFilter,
|
onSurface: true,
|
||||||
options: const [
|
labelOnlySelected: true,
|
||||||
NooSegmentOption(
|
value: files.filesTypeFilter,
|
||||||
value: FilesTypeFilter.all,
|
onChanged: files.setFilesTypeFilter,
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: FilesTypeFilter.filesOnly,
|
label: 'All',
|
||||||
icon: LucideIcons.file,
|
),
|
||||||
label: 'Files',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.filesOnly,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.file,
|
||||||
value: FilesTypeFilter.foldersOnly,
|
label: 'Files',
|
||||||
icon: LucideIcons.folder,
|
),
|
||||||
label: 'Folders',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.foldersOnly,
|
||||||
],
|
icon: LucideIcons.folder,
|
||||||
|
label: 'Folders',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
HiddenFilesFilterRow(
|
||||||
children: [
|
value: files.hiddenFilter,
|
||||||
NooSettingsRow(
|
onChanged: files.setHiddenFilter,
|
||||||
icon: LucideIcons.eye,
|
|
||||||
label: const Text('Show hidden files'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: files.showHiddenFiles,
|
|
||||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showStorageScope)
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.hardDrive,
|
|
||||||
label: const Text('External storage'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: files.storageScope == StorageScope.external,
|
|
||||||
onChanged: (external) => files.setStorageScope(
|
|
||||||
external ? StorageScope.external : StorageScope.cloud,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
if (showStorageScope) ...[
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
StorageScopeRow(
|
||||||
|
value: files.storageScope,
|
||||||
|
onChanged: files.setStorageScope,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import 'noo/core/noo_segmented_control.dart';
|
||||||
|
|
||||||
|
/// "Hidden files" row of the Files/Photos filter sheets: hide them (the
|
||||||
|
/// default), show only them, or show them alongside everything else. Same
|
||||||
|
/// icon-always/label-when-selected pill as the type filter above it in
|
||||||
|
/// both sheets.
|
||||||
|
class HiddenFilesFilterRow extends StatelessWidget {
|
||||||
|
final HiddenFilesFilter value;
|
||||||
|
final ValueChanged<HiddenFilesFilter> onChanged;
|
||||||
|
|
||||||
|
const HiddenFilesFilterRow({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _FilterModeRow<HiddenFilesFilter>(
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
title: 'Hidden files',
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.hide,
|
||||||
|
icon: LucideIcons.eyeOff,
|
||||||
|
label: 'Hide',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.only,
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
label: 'Only hidden',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.include,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "External storage" row of the Files/Photos filter sheets: cloud only
|
||||||
|
/// (the default), only external storage, or both. Files shows the external
|
||||||
|
/// items in a collapsible section for [StorageScope.all].
|
||||||
|
class StorageScopeRow extends StatelessWidget {
|
||||||
|
final StorageScope value;
|
||||||
|
final ValueChanged<StorageScope> onChanged;
|
||||||
|
|
||||||
|
const StorageScopeRow({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _FilterModeRow<StorageScope>(
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
title: 'External storage',
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.cloud,
|
||||||
|
icon: LucideIcons.cloud,
|
||||||
|
label: 'Cloud only',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.external,
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
label: 'Only external',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.all,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An icon + title line above [child] - the shape every row of the Files/
|
||||||
|
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||||
|
/// control, which lives in its own file.
|
||||||
|
class FilterSection extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const FilterSection({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: colors.fg2),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModeRow<T> extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final T value;
|
||||||
|
final ValueChanged<T> onChanged;
|
||||||
|
final List<NooSegmentOption<T>> options;
|
||||||
|
|
||||||
|
const _FilterModeRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FilterSection(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
child: NooSegmentedControl<T>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
labelOnlySelected: true,
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
|
|
||||||
|
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
||||||
|
final BorderRadius? radius;
|
||||||
|
|
||||||
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
||||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
/// explicit value (e.g. from user settings) to make it adjustable.
|
||||||
final double blurSigma;
|
final double blurSigma;
|
||||||
@@ -35,6 +38,7 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.borderRadius = 0,
|
this.borderRadius = 0,
|
||||||
|
this.radius,
|
||||||
this.blurSigma = 20,
|
this.blurSigma = 20,
|
||||||
this.opacity = 0.8,
|
this.opacity = 0.8,
|
||||||
this.color,
|
this.color,
|
||||||
@@ -42,15 +46,16 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||||
final tint = color ?? context.nooColors.surface;
|
final tint = color ?? context.nooColors.surface;
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: shape,
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tint.withValues(alpha: opacity),
|
color: tint.withValues(alpha: opacity),
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: shape,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final VoidCallback? onSecondaryTap;
|
final VoidCallback? onSecondaryTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
/// Overflow menu tap. The button is hidden when null.
|
/// Overflow menu tap. The button is hidden when null.
|
||||||
final VoidCallback? onMore;
|
final VoidCallback? onMore;
|
||||||
@@ -171,6 +172,7 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
this.onTap,
|
this.onTap,
|
||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.onSecondaryTap,
|
this.onSecondaryTap,
|
||||||
|
this.onLongPress,
|
||||||
this.onMore,
|
this.onMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +195,7 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onDoubleTap: onDoubleTap,
|
onDoubleTap: onDoubleTap,
|
||||||
onSecondaryTap: onSecondaryTap,
|
onSecondaryTap: onSecondaryTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: NooSizes.rowDesktop,
|
height: NooSizes.rowDesktop,
|
||||||
child: _TableColumns(
|
child: _TableColumns(
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class NooGroupedList extends StatefulWidget {
|
|||||||
final String? aside;
|
final String? aside;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
final Widget? footer;
|
final Widget? footer;
|
||||||
|
|
||||||
|
/// Shown between [label] and the card (e.g. a `NooInfoNote`).
|
||||||
|
final Widget? notice;
|
||||||
final bool collapsible;
|
final bool collapsible;
|
||||||
final bool initiallyExpanded;
|
final bool initiallyExpanded;
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ class NooGroupedList extends StatefulWidget {
|
|||||||
this.aside,
|
this.aside,
|
||||||
required this.children,
|
required this.children,
|
||||||
this.footer,
|
this.footer,
|
||||||
|
this.notice,
|
||||||
this.collapsible = false,
|
this.collapsible = false,
|
||||||
this.initiallyExpanded = true,
|
this.initiallyExpanded = true,
|
||||||
});
|
});
|
||||||
@@ -58,6 +62,10 @@ class _NooGroupedListState extends State<NooGroupedList> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
if (widget.notice != null) ...[
|
||||||
|
widget.notice!,
|
||||||
|
const SizedBox(height: NooSpace.md),
|
||||||
|
],
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
duration: NooMotion.fast,
|
duration: NooMotion.fast,
|
||||||
sizeCurve: NooMotion.ease,
|
sizeCurve: NooMotion.ease,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// Tinted info callout (info-soft fill, info icon) for a short notice that
|
||||||
|
/// must be noticed immediately, unlike a muted footer/subtitle line.
|
||||||
|
class NooInfoNote extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const NooInfoNote({super.key, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.infoSoft,
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||||
|
border: Border.all(color: colors.info.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 1),
|
||||||
|
child: Icon(LucideIcons.info, size: 18, color: colors.info),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg1, height: 1.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,21 +333,30 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: _satelliteBody(colors),
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surface,
|
|
||||||
border: Border.all(color: colors.line),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: const [nooDialogShadow],
|
|
||||||
),
|
|
||||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _satelliteBody(NooColors colors) {
|
||||||
|
final disc = Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: frosted
|
||||||
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
|
: colors.surface,
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
|
),
|
||||||
|
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||||
|
);
|
||||||
|
return frosted
|
||||||
|
? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur)
|
||||||
|
: disc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IosItem extends StatelessWidget {
|
class _IosItem extends StatelessWidget {
|
||||||
|
|||||||
@@ -48,58 +48,68 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar);
|
Size get preferredSize => const Size.fromHeight(outerHeight);
|
||||||
|
|
||||||
|
static const _margin = EdgeInsets.fromLTRB(12, 8, 12, 4);
|
||||||
|
|
||||||
|
/// Bar height plus the margin that makes it float.
|
||||||
|
static const double outerHeight = NooSizes.toolbar + 12;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
|
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
|
||||||
|
|
||||||
return Material(
|
return Padding(
|
||||||
color: backgroundColor ?? colors.surface,
|
padding: _margin,
|
||||||
child: Container(
|
child: Material(
|
||||||
height: NooSizes.toolbar,
|
color: backgroundColor ?? colors.surface,
|
||||||
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
shape: RoundedRectangleBorder(
|
||||||
border: Border(bottom: BorderSide(color: colors.line)),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
side: BorderSide(color: colors.line),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Container(
|
||||||
spacing: 12,
|
height: NooSizes.toolbar,
|
||||||
children: [
|
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
|
||||||
if (showHistoryNav)
|
child: Row(
|
||||||
Padding(
|
spacing: 12,
|
||||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
children: [
|
||||||
child: Row(
|
if (showHistoryNav)
|
||||||
mainAxisSize: MainAxisSize.min,
|
Padding(
|
||||||
spacing: 4,
|
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||||
children: [
|
child: Row(
|
||||||
_HistoryButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: LucideIcons.chevronLeft,
|
spacing: 4,
|
||||||
tooltip: 'Back',
|
children: [
|
||||||
onTap: onBack,
|
_HistoryButton(
|
||||||
),
|
icon: LucideIcons.chevronLeft,
|
||||||
_HistoryButton(
|
tooltip: 'Back',
|
||||||
icon: LucideIcons.chevronRight,
|
onTap: onBack,
|
||||||
tooltip: 'Forward',
|
),
|
||||||
onTap: onForward,
|
_HistoryButton(
|
||||||
),
|
icon: LucideIcons.chevronRight,
|
||||||
],
|
tooltip: 'Forward',
|
||||||
|
onTap: onForward,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
child: titleWidget ?? Text(title ?? ''),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
if (search != null) SizedBox(width: 260, child: search),
|
||||||
child: Align(
|
...actions,
|
||||||
alignment: AlignmentDirectional.centerStart,
|
],
|
||||||
child: DefaultTextStyle(
|
),
|
||||||
style: titleStyle,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
child: titleWidget ?? Text(title ?? ''),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (search != null) SizedBox(width: 260, child: search),
|
|
||||||
...actions,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: NooSpace.md),
|
||||||
Expanded(child: androidTitleTrailing!),
|
Expanded(child: androidTitleTrailing!),
|
||||||
] else
|
] else
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ class NooLayout {
|
|||||||
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
||||||
static const double desktopBreakpoint = 900;
|
static const double desktopBreakpoint = 900;
|
||||||
|
|
||||||
static bool isDesktop(BuildContext context) =>
|
/// Shortest window side that counts as a tablet-class window (the usual
|
||||||
MediaQuery.sizeOf(context).width >= desktopBreakpoint;
|
/// 600dp rule). A phone in landscape is wide but short, so it stays on the
|
||||||
|
/// mobile layout.
|
||||||
|
static const double tabletShortestSide = 600;
|
||||||
|
|
||||||
|
static bool isDesktop(BuildContext context) {
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
return size.width >= desktopBreakpoint &&
|
||||||
|
size.shortestSide >= tabletShortestSide;
|
||||||
|
}
|
||||||
|
|
||||||
static NooNavStyle navStyle(BuildContext context) =>
|
static NooNavStyle navStyle(BuildContext context) =>
|
||||||
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
||||||
@@ -24,4 +32,16 @@ class NooLayout {
|
|||||||
|
|
||||||
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
|
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
|
||||||
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
|
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
|
||||||
|
|
||||||
|
/// Grid column count for [minTile]-wide tiles, never fewer than [phone]
|
||||||
|
/// (so phones keep their usual count while wide non-sidebar windows, e.g. a
|
||||||
|
/// tablet in portrait, get more instead of a few huge tiles).
|
||||||
|
static int gridColumns(
|
||||||
|
BuildContext context, {
|
||||||
|
required int phone,
|
||||||
|
required double minTile,
|
||||||
|
}) {
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
return (width / minTile).floor().clamp(phone, 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ class NooShareSection extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: NooText.sectionTitle.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
?trailing,
|
?trailing,
|
||||||
],
|
],
|
||||||
@@ -60,11 +63,16 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
final String name;
|
final String name;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final bool owner;
|
final bool owner;
|
||||||
|
|
||||||
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
|
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
|
||||||
final String permission;
|
final String permission;
|
||||||
final VoidCallback? onPermissionTap;
|
final VoidCallback? onPermissionTap;
|
||||||
|
final bool permissionLoading;
|
||||||
final String ownerLabel;
|
final String ownerLabel;
|
||||||
|
|
||||||
|
/// Replaces the owner label / permission pill (e.g. a plain search result).
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
const NooPersonAccessRow({
|
const NooPersonAccessRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.avatar,
|
required this.avatar,
|
||||||
@@ -73,7 +81,9 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
this.owner = false,
|
this.owner = false,
|
||||||
this.permission = 'Can edit',
|
this.permission = 'Can edit',
|
||||||
this.onPermissionTap,
|
this.onPermissionTap,
|
||||||
|
this.permissionLoading = false,
|
||||||
this.ownerLabel = 'Owner',
|
this.ownerLabel = 'Owner',
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -113,10 +123,16 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: NooSpace.sm),
|
const SizedBox(width: NooSpace.sm),
|
||||||
if (owner)
|
if (trailing != null)
|
||||||
|
trailing!
|
||||||
|
else if (owner)
|
||||||
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
||||||
else
|
else
|
||||||
NooPermissionPill(label: permission, onTap: onPermissionTap),
|
NooPermissionPill(
|
||||||
|
label: permission,
|
||||||
|
onTap: onPermissionTap,
|
||||||
|
loading: permissionLoading,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -128,8 +144,14 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
class NooPermissionPill extends StatelessWidget {
|
class NooPermissionPill extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool loading;
|
||||||
|
|
||||||
const NooPermissionPill({super.key, required this.label, this.onTap});
|
const NooPermissionPill({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.onTap,
|
||||||
|
this.loading = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -147,9 +169,21 @@ class NooPermissionPill extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: NooText.label.copyWith(height: 1, color: colors.fg1),
|
||||||
|
),
|
||||||
const SizedBox(width: NooSpace.xxs),
|
const SizedBox(width: NooSpace.xxs),
|
||||||
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
|
loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.fg2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ class _NooSheetBody extends StatefulWidget {
|
|||||||
class _NooSheetBodyState extends State<_NooSheetBody> {
|
class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||||
bool _needsPeek = false;
|
bool _needsPeek = false;
|
||||||
|
|
||||||
|
// Keeps the sheet's content (and its State) alive when the body swaps
|
||||||
|
// between the plain and draggable layouts, instead of rebuilding it.
|
||||||
|
final _contentKey = GlobalKey();
|
||||||
|
|
||||||
void _handleOverflow(ScrollMetrics metrics) {
|
void _handleOverflow(ScrollMetrics metrics) {
|
||||||
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
|
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
|
||||||
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
|
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
|
||||||
@@ -84,6 +88,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
|||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
key: _contentKey,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -106,40 +111,51 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
// A modal bottom sheet doesn't avoid the keyboard itself, so a focused
|
||||||
child: LayoutBuilder(
|
// field would be covered. Inset by the keyboard and, while it's up, let
|
||||||
builder: (context, constraints) {
|
// the content use all the space above it.
|
||||||
if (!_needsPeek) {
|
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
return ConstrainedBox(
|
final keyboardUp = keyboard > 0;
|
||||||
constraints: BoxConstraints(
|
return Padding(
|
||||||
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
|
padding: EdgeInsets.only(bottom: keyboard),
|
||||||
),
|
child: SafeArea(
|
||||||
child: _buildScrollable(null),
|
bottom: !keyboardUp,
|
||||||
);
|
child: LayoutBuilder(
|
||||||
}
|
builder: (context, constraints) {
|
||||||
return DraggableScrollableSheet(
|
if (!_needsPeek) {
|
||||||
initialChildSize: _kNooSheetPeekFraction,
|
return ConstrainedBox(
|
||||||
minChildSize: _kNooSheetMinFraction,
|
constraints: BoxConstraints(
|
||||||
maxChildSize: _kNooSheetMaxFraction,
|
maxHeight: keyboardUp
|
||||||
expand: false,
|
? constraints.maxHeight
|
||||||
builder: (context, scrollController) {
|
: constraints.maxHeight * _kNooSheetPeekFraction,
|
||||||
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
|
),
|
||||||
// route on its own when dragged down to its floor - it just
|
child: _buildScrollable(null),
|
||||||
// stops resizing there - so pop explicitly once it's been
|
|
||||||
// dragged (near) all the way down, matching a native
|
|
||||||
// peek sheet's swipe-to-dismiss.
|
|
||||||
return NotificationListener<DraggableScrollableNotification>(
|
|
||||||
onNotification: (notification) {
|
|
||||||
if (notification.extent <= notification.minExtent + 0.01) {
|
|
||||||
Navigator.of(context).maybePop();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: _buildScrollable(scrollController),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
return DraggableScrollableSheet(
|
||||||
},
|
initialChildSize: _kNooSheetPeekFraction,
|
||||||
|
minChildSize: _kNooSheetMinFraction,
|
||||||
|
maxChildSize: _kNooSheetMaxFraction,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
|
||||||
|
// route on its own when dragged down to its floor - it just
|
||||||
|
// stops resizing there - so pop explicitly once it's been
|
||||||
|
// dragged (near) all the way down, matching a native
|
||||||
|
// peek sheet's swipe-to-dismiss.
|
||||||
|
return NotificationListener<DraggableScrollableNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
if (notification.extent <= notification.minExtent + 0.01) {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: _buildScrollable(scrollController),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||