diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index cc65fa1..77e69cb 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -57,15 +57,16 @@ new provider instance): saved-accounts list, which one is active, `sessionGeneration` — see below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`, `loginFlowStatus`), the active `NextcloudService` instance, and login lock - (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/ - `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/ + (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three + independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/ `addAccountActivatedListener` (plain `List`) so sibling controllers — constructed after `SessionController` and unable to hold a forward reference to it — can react to login/logout/account-switch without a circular dependency. - [`SettingsController`](../../lib/providers/settings_controller.dart) — global UI prefs independent of login state: theme mode/seed color/dynamic - color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab + color, bottom-bar opacity/blur, avatar and selection action-bar positions, + tap-tab-to-scroll-top, seek bar style, tab order/visibility/default (`requestedTab`/`requestTab`/ `consumeRequestedTab`), swipe actions. - [`FilesController`](../../lib/providers/files_controller.dart) — the @@ -282,11 +283,13 @@ the sidebar): - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar` for *every* tab (previously only Files had shell-level top chrome, with the rest building their own via `SyncedHeaderScaffold`) - iOS gets a - large title, an inline search field, a `plus` action on Files only (no - other tab has a create/upload flow), and the account avatar; Android + large title with Upload and the top account avatar on the same row, + and an inline search field below; Android gets a compact title row with `search`/avatar actions, relying on an - extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar - icon for upload. Rather than one shared instance in `Scaffold.appBar`, + extended `NooFab` instead of a top-bar upload button. Both use the same + size preference: Auto expands on Files/Photos and collapses elsewhere, + Mini always collapses, and Expanded always shows Upload. Rather than one + shared instance in `Scaffold.appBar`, `MainShellView` builds a separate `AppTopBar` per tab (labelled for that tab) and each tab plants its own as the first sliver in its own `CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`, @@ -312,8 +315,13 @@ the sidebar): section yet). `SettingsController.navMenuStyle` (Settings → Appearance → "Navigation menu") offers an alternative to the hamburger/drawer pair: the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`) - instead, a dropdown holding the same hidden-tabs + Settings content - - see `styling.md`'s Gotchas for the wiring. + instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a + card from the top or bottom avatar; `AvatarNavigationBody` moves only + content for the top popup. The bottom popup overlays content. Floating + mode moves the avatar into the popup and hands its satellite slot to + Search; attached mode keeps the avatar and Search in the full-width bar + and expands the popup upward from it. Position is a global setting. + See `styling.md` (Gotchas) for the wiring, motion and dismissal behavior. - **Wide tablet:** a `NooSidebar` (account card, pinned tabs, divider, remaining tabs, storage meter, Settings) sits beside a `NooToolbar` (tab title, search, an "Upload" action on Files/Photos) over the same diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 8e1f6ca..8bf9811 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -168,10 +168,10 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 - Shared: `users` in fg-3. **Swipe action** -- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent. +- The row slides and uncovers a 96px action block. Delete is white on danger-fill; all other actions are white on accent. - The block has a 20px icon above a 12/600 label. - Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it. -- Swipe actions are set in Settings → Swipe on a file. +- Swipe actions are set in Settings → Swipe on a file. Both directions offer None and every action-bar action: Favorite, Share, Download, Delete, Copy, Move, Rename, Sync to device, Details. They use the same single-item handlers; Delete still asks for confirmation. **Selection action bar** (Files, Photos, Favorites - anywhere with multi-select) @@ -289,7 +289,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached | | Overflow icon | `ellipsis` | `ellipsis-vertical` | | Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. | -| Biometric label | "Lock with Face ID" | "Lock with fingerprint" | +| App lock label | "App lock" | "App lock" | ### Wide tablet @@ -359,7 +359,9 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The priority order for the Selection action bar's bulk actions (favorite, share, download, delete, copy, move, rename, sync, details) - the first few (3 mobile / 4 wide tablet) land in the bar's fixed inline - slots, the rest sit behind "More". See §2 "Selection action bar". + slots, the rest sit behind "More". A live preview above the list uses + the actual bar and updates on reorder, including its More sheet. Preview + actions show feedback without touching files. See §2 "Selection action bar". 9. Swipe on a file Mobile is a two-level menu, the way native iOS/Android Settings apps diff --git a/.claude/context/server.md b/.claude/context/server.md index 4080700..d777aee 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too). own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting Flutter's own default to already be high enough. +**The three locks are independent** (Settings -> Security; +`SessionController`): `loginLockEnabled` (unlock to open the app - the only one +`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to +switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden +files). Each gate works with the others off - `passGate(gate, reason)` +prompts whenever its own `gate` is on, whether or not login lock is. Turning +any of them on **or off** needs a successful `AppLockService.authenticate` +(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set; +off needs it so someone with a momentarily unlocked phone can't remove it), +and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle +dependency is gone. Tests replace the prompt through +`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`. + `isRestoringSession` still gates the splash screen until the above resolves — see `standards.md` for why widget tests must mock both storage channels rather than relying on this async path throwing naturally. @@ -811,3 +824,243 @@ rather than relying on this async path throwing naturally. bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for files, 15 for folders), keeping the existing reshare bit, plus Remove access. + +## Native services on iOS (in progress) + +The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, +`upload_service`, `download_service`, `sync_service`, plus their status +`EventChannel`s) were written for Android in Kotlin. iOS implements +`upload_service` (+ its status stream), `download_service` and +`share_intent` and `sync_service` so far (`ios/Runner/Native/` + +`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so +calls throw `MissingPluginException`. `lib/services/native_channel.dart` +makes that safe until each one is built in Swift: + +- `invokeIfAvailable` - a missing implementation returns null. For cold-start + checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and + scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`, + `finishPick`/`cancelPick`). +- `invokeOrExplain` - throws `NativeServiceUnavailable("")`, 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//...` 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 `/remote.php/dav/files//...` + 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.`) 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='`). + +### 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//`) and hosts the SwiftUI + `SharePickerView` (`ShareModel` holds its state). +2. It reads the accounts from the shared Keychain (`SharedAccountStore` -> + `SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND` + Depth 1, parsed by `DavFolderParser`). With **several accounts an account + list comes first** (the app's active one is marked "Active"; "Change + account" returns to it); with one it goes straight to its folders. The + Dart side keeps the keychain data current: `ShareAccountSync` + (`lib/services/share_account_service.dart`, started from the shell's + `initState`) republishes - through the native `share_account` channel's + `setAccounts` - when an account becomes ready or when its change + signature moves: the account list/active account, the three lock + settings, or the active account's hidden-files filter / storage scope; + sign-out calls + `clearAccount`. It publishes every saved account that has a stored + password and isn't signed out, each with its **own Files hidden-files + filter** (`FilesController.savedHiddenFilter`). + - **Toggles**: the folder list has two menu chips, **Hidden** (hide / only + hidden / all) and **Storage** (cloud only / only external / all + + external), each starting at the chosen account's own app setting + (`hiddenFilter`, `storageScope` published per account) and changeable for + this share only - nothing is written back. The list is fetched once per + folder (`DavClient.listFolders` returns everything, hidden and external + included, with `nc:mount-type` requested) and filtered in memory + (`ShareModel.visibleFolders`), so flipping a toggle just returns to the + top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when + it *or any ancestor* starts with a dot. External storage: only a mount's + *root* has `mount-type=external`, so `ShareModel` remembers the roots it + has seen and treats anything under one as external too (the app's own + filter doesn't, which empties its list inside a mount). "All" lists + external folders in their own "External storage" group. + - **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening + the sheet with the account's filter already not `hide`, asks for + Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the + `.deviceOwnerAuthentication` policy - biometrics with passcode fallback, + like `local_auth` with `biometricOnly: false`); cancelling keeps them + hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never + asks. + - **Account switching**: choosing any account other than the app's + active one asks for the same unlock when "lock account switching" is on. + **Every gated action prompts every time**, exactly like the app - no + unlock is remembered for the sheet (an earlier version cached one, so + later account picks / hidden toggles skipped Face ID). The only + merging is within one action: picking another account whose own filter + already shows hidden folders is a single prompt covering both + (`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested). + Declining the account unlock cancels the pick; declining only the + hidden unlock shows the account with hidden folders hidden. + - Opening the sheet itself is not gated - only these two actions are + (as in the app, where login lock guards launch and these are separate + locks). +3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background* + `URLSession` (`dev.ayushya.noo.transfers.share`, with + `sharedContainerIdentifier`) that outlives the extension, and posts + "Uploading N files...". Results go to whichever process is alive: + files that finish while the sheet is still open are counted by the + extension's `ShareUploadDelegate`; the rest by the app, which recreates + that session at launch (`TransferManager.reconnect`) and is relaunched by + the system. Either way `UploadResults.handle` deletes the staged copy, + counts the file in `TransferBatchStore` (batch totals live in the App + Group's `UserDefaults` because the two are different processes) and + posts the "Uploaded N files" summary on the last one; an upload the + extension finished leaves a note (`noteFinishedUpload`) so the app + refreshes that folder on its next activation. **Don't give the + extension's session no delegate** - a fast upload then finishes unseen and + the invalidated session is discarded before the app can ever hear of it + (found the hard way: no notification, no count). +4. Fallbacks: with no account (or the user taps "Choose a folder later in + Noo") the files go into `SharedInbox/pending.json` instead and a local + notification asks the user to open Noo; the app consumes that through + `share_intent` (`getInitialShare` at cold start, `share_intent/new` on + `UIApplication.didBecomeActiveNotification`) into the same Dart + `ShareUploadView` flow as an Android share, and `TransferManager. + startUpload` deletes the inbox copy once staged. + +The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared` +(`keychain-access-groups` in both `.entitlements`); both Info.plists also +carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the +two processes always agree on the team-prefixed id. Everything is stored as +JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`). + +`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`, +`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that +isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is +`dev.ayushya.noo.ShareExtension` and its version/build come from the same +`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a +mismatch); it's embedded by an "Embed Foundation Extensions" phase placed +*before* Flutter's script phases (after them Xcode reports a dependency +cycle). The simulator honours the App Group and keychain group without a +signing team; a real device needs a team that owns both ids. + +### iOS device sync (`ios/Runner/Native/SyncEngine/`) + +A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/ +`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service` +channel (+ `/status` event stream) so the Dart side is unchanged apart from +`cancelAccount`'s `forget` flag (below). Same rules: only paths the user +synced are mirrored; brand-new local files are never uploaded (only edits to +already-synced files are); a file edited on both sides is a conflict (neither +copy is touched, "Keep local"/"Use server" resolves it); a local deletion is +repaired by re-downloading; the mirror lives at +`/sync//...` (what Dart's +`SyncService.baseDirectory()` reads for the Offline tab, excluded from +backup) with state in `<...>/sync-state//*.json`. + +- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient` + (PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one + account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator` + (runs, `BGTaskScheduler`, notifications), and the `registerSync` channel + handlers in `NativeServices.swift`. +- **Safeguards (each has a regression test in `SyncEngineTests`, which drives + the real runner against an in-memory fake Nextcloud):** an unreachable + server, a non-207, or a PROPFIND answer that is truncated, not a + `multistatus`, empty, or has an href outside the account root / with dot + components throws `SyncRemoteUnavailable` and the path is skipped - never + diffed as empty or partial (which would delete local files); unreadable + recorded state makes the run do nothing (`SyncStateUnreadable`) instead of + treating every file as untracked and overwriting local edits; every + server-supplied path is resolved through `LocalFS.resolve` (no escaping the + mirror) and entries that would land on the same local file (case or Unicode + spellings) are skipped, not downloaded over each other; downloads go to a + temp file and replace atomically, never replacing a folder; empty-folder + pruning uses `rmdir` and only on a successful empty read; a root's etag + shortcut marker is only written when the path synced cleanly *and* the state + was saved. +- **Server deletions:** remove the local copy even if edited locally, matching + Android. +- **Deliberate differences from Android:** after our own upload the file's + etag is re-read so it isn't downloaded back; state is flushed every 20 transfers; conflicts are kept + per account on the status bus; downloads write to a temp file first. No + ongoing progress notification (a run posts a summary, and conflict + notifications with Keep local / Use server actions). +- **Running:** every run - "Sync now", the in-app refresh, and background - + goes through `SyncCoordinator.startRun`: one tracked run per account + (`force` supersedes it, a quiet check leaves it alone), serialised by one + cancellable `AsyncMutex`, tagged with a per-account *generation* so a run + that was only queued never starts for an account that was cancelled or had a + path removed meanwhile. `removeLocalSync` cancels the run and takes the same + mutex, so a finishing run can't write stale state back over it. +- **Background:** `BGAppRefreshTask` + `BGProcessingTask` + (`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch + + processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers + registered before launch finishes). `intervalMinutes` (floored at 15, like + Android) is only the *earliest* start - iOS picks when. The Files cache + settings explain this on iOS; the 15-minute Android warning appears only + on Android for intervals below 15 minutes. Wi-Fi-only is + checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require + it. An expiring task cancels its runs (they save state as they go) and is + completed exactly once (`OnceGate`). **Not testable in the simulator** + (needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier` + debugger command). +- **Simulator verification:** a temporary debug control ran the native + background slot and confirmed files update for a non-active saved account. + The control and its channel handler were removed after verification. + `startBackgroundSlot` retains a regression test for periodic eligibility. + OS wake-up delivery and background execution time limits remain unverified. +- **Credentials:** `SyncConfigStore` keeps each account's server, auth header, + paths, interval and notify flag as one Keychain item + (`AfterFirstUnlock`, so a background run works with the phone locked). + Conflict notification actions look the account up there - nothing secret is + in a notification. `sync_service.cancel` takes `forget` (default true: + signed out/removed -> credentials dropped; false: only background sync was + turned off -> kept for manual runs and conflict actions). Dart sends + `forget: false` only for "paths configured but no background interval". +- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on + macOS) swallows any directory named `Sync` - hence `SyncEngine/`. + +Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes. diff --git a/.claude/context/standards.md b/.claude/context/standards.md index 18d3836..dc8b0b3 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -113,9 +113,11 @@ in this order: a local `android/key.properties` (gitignored — points at a gitignored keystore file, e.g. `android/app/release-keystore.jks`), then CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/ `_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/ -build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and -`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing -the `.aab` Play Console wants), then falls back to the debug key if neither +build.yml`, triggered by `RC-Android-*` tags and producing a sideloadable +APK, and `.gitea/workflows/release.yml`, triggered by `Release-*` tags and +producing the `.aab` Play Console wants; `.gitea/workflows/ios.yml` builds +and uploads the signed iOS app to TestFlight on `RC-iOS-*` and `Release-*` +tags, on a self-hosted macOS runner), then falls back to the debug key if neither is configured. As long as the same dedicated release keystore backs both `key.properties` locally and the Gitea secrets, local release builds and CI-built release APKs share one signature, so `adb install -r` works diff --git a/.claude/context/styling.md b/.claude/context/styling.md index c0d51b1..dc901fe 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -29,6 +29,13 @@ widgets. Key points: whole app in `main.dart`. When available and `useDynamicColor` is on, the OS-provided `ColorScheme` wins over the seed color — always thread both `dynamicScheme` and `useDynamicColor` through when adding a theme knob. + `NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks + `surface` (cards, selection bar, rows) from the container roles so it + stays lighter than `bg`: `surfaceContainerLowest` (white) in light, + `surfaceContainer` in dark. The dynamic_color package seeds the container + roles from the primary color rather than the OS palette, and in light mode + `surfaceContainerLow` is slightly *darker* than the OS `surface`, which + made cards blend into the page ("the selection bar has no background"). - **Font**: Inter via `google_fonts`, applied through `GoogleFonts.interTextTheme(...)`. Font files ship in `assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and @@ -79,7 +86,7 @@ Catalog: | Folder | Components | |---|---| -| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | +| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | | `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` | | `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (wide tablet), `NooSwipeAction` | | `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` | @@ -102,6 +109,17 @@ Gotchas: trigger it) but also fires it directly if the drag goes far enough (past 1.8x the block's width) before release - a full swipe-through does delete in one gesture, it's not tap-only anymore. +- Swipe settings offer every `SelectionActionKind` plus None. `SwipeAction` + persists the existing enum names and maps to that shared action catalog; + `FilesView` uses the single-item action-bar handlers, with the existing + delete confirmation retained. Labels/icons update for favorited and synced + items. Swipes remain disabled during selection, picking, and offline browsing. +- Settings → Action bar shows the real `NooSelectionBar` above its reorder + list. It updates immediately and supports the same More sheet; preview + actions only show feedback, never operate on files. The bar scales down + inside Settings' narrower column while keeping the mobile/tablet split. +- The Security setting is labelled **App lock** on every platform; its + subtitle still explains device PIN or biometric authentication. - Always read colors through `context.nooColors`, never `Theme.of(context).colorScheme` - the latter is Flutter's own Material 3 scheme, reseeded by the user's accent color choice (`AppTheme.light`/ @@ -114,7 +132,8 @@ Gotchas: toggle, Shares' scope switcher), but pass `onSurface: true` for one placed inside a sheet or dialog (already `colors.surface`), or its pill track blends invisibly into the sheet instead of reading as a grouped - control (Files'/Photos' sort and type-filter sheets do this). + control (Files'/Photos' filter sheets and Settings controls on surface + rows use the same `surface2` track). - Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()` on its scrollable child, or pull-to-refresh silently can't be triggered once the list is short enough to fit the viewport (empty, or one item) - @@ -133,6 +152,32 @@ blocks are noted where they matter: `NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/ view-mode row shared by Files and Offline (`showStorageScope: false` for Offline), and reused as-is by Favorites. +- Selection action bars in Files, Photos, and Favorites have a global + Settings → Action bar → Position choice: Top (default) or Bottom, + persisted as `ui_selection_bar_position`. Top stays pinned in the content + header; Bottom uses `SelectionBarOverlay` above navigation (or the content + pane's bottom on desktop), with extra list clearance. The overlay uses + Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation + and safe areas are accounted for without counting their height twice. + The shell hides Android's Upload FAB during selection via view callbacks. + The mobile Action bar settings use one grouped card for Position and + the reorder list, with no nested rounded corners or repeated page title. + Their header backdrop is transparent, so content scrolls behind the inset + `surface` card. A `line` border and `nooDialogShadow` match the floating + bottom bar's solid treatment. +- Hidden files and external storage are each a three-way segmented row + (`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`, + in both the Files and Photos filter sheets), not toggles: + `HiddenFilesFilter` hide (default) / only / include, and `StorageScope` + cloud (default) / external / all. Files and Photos each persist their own + hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old + `ui_show_hidden*` bools are still read as a fallback, `true` → include); + `StorageScope` stays shared. Photos uses the same segmented pill for + Favorites (All / Favorites only). Leaving `hide` is still behind the + hidden-files lock gate. With `StorageScope.all`, Files splits the listing: + regular items first, then a collapsible "External storage" section + (`_ExternalStorageHeader`, local `_externalExpanded` state, default open) + holding the external ones; Photos/Favorites just merge them into one list. - `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb trail Files uses in place of the shared [`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs` @@ -227,9 +272,27 @@ blocks are noted where they matter: instead of a corner badge. `NooBottomBar` later gained its own, unrelated `NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") - don't confuse the two: this one is still non-blurred, just inset with a - `line` border and `nooDialogShadow` (the app's one other shadow user - see + `line` border and `nooDialogShadow` (also used by floating selection bars - see that constant's doc comment) instead of edge to edge (no opacity/blur knob - either). The host `Scaffold` needs `extendBody: true` while it's active + either). `floating` ignores `NooNavStyle` and always uses the Android row + (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; + only the attached bar still has a distinct iOS row. A separate, orthogonal + `SettingsController.bottomBarFrosted` toggle (Settings → Appearance → + "Frosted glass bottom bar", off by default, both platforms and both bar + styles) swaps the bar's solid `surface` for a translucent one over a + `BackdropFilter` blur and drops + `nooDialogShadow` (it would show through the glass). Strength is user-set: + `FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the + old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity + (0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown + only while frosted glass is on; both persist + (`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as + `NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once + the sliders are moved off all three. A frosted bar only + reads as glass with content behind it, so the shell uses + `NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and + `bottomBarClearance` (and Files' own trailing sliver) reserve the bar's + footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active (`main.dart` already wires this off `SettingsController.bottomBarStyle`), which also means every tab's own scrollable list has to reserve enough bottom padding to clear the bar - nothing does that automatically once the @@ -242,44 +305,50 @@ blocks are noted where they matter: `onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one - use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`, anywhere that needs the *current* cap on regular tabs. -- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`, - Settings → Appearance → "Navigation menu") picks what opens hidden tabs + - Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or - the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart) - instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu` - mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and - `ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) - - a caller passing a custom `onTap` *must* also pass a matching `label`, or - the tooltip/semantics still say "Accounts" for a button that no longer - opens the account switcher. In `avatarMenu` mode `main.dart` also sets - `Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open - the drawer. The menu header has a chevron that expands an account section - (other saved accounts to switch to, "Add Account", "Manage Accounts") - above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of - `showGeneralDialog` directly (`barrierColor: Colors.transparent` + - `barrierDismissible: true` for a non-dimming click-outside-to-close menu, - not a modal flow) - there's no existing anchored-popup primitive here - (`PopupMenuButton`'s own width doesn't stretch to a full content column), - so don't reach for `showNooSheet`/`showNooDialog` for something shaped - like this. It's positioned just past the status bar (`SafeArea`'s own - inset, not the top bar's full height on top of that) so it covers the - top bar - including the tab title - rather than sitting below it, and - its card carries two stacked `boxShadow`s rather than just - `nooDialogShadow` alone: that one shadow's blur is wide and soft enough - to read as basically invisible on a small card over a dark theme's - near-black `bg` (a dark, diffuse shadow needs real density close to the - edge to be visible against an already-dark backdrop), so a second, - tighter, more opaque contact shadow underneath it gives real elevation - in both themes. The card's border can go missing wherever an opaque row - sits against it too (every corner but the header's, which has no - full-bleed fill of its own) if a `Container` combines `border` with its - own `clipBehavior` - that paints the border as part of the *outer* - decoration, then the clipped child on top right up to the same boundary, - with no gap for the border's own stroke to show through. Fixed the same - way any bordered-and-clipped `Container` should be: no `clipBehavior` on - the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius - reduced by that same 1px) around the filled, clipped content instead, so - it never paints over the border. Also added +- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup + navigation (Settings → Appearance → Navigation menu). Avatar mode exposes + a persisted Avatar position choice (`ui_avatar_position`, Top by default). + `AvatarNavigationHost` owns one 420ms open / 320ms close animation using + `easeInOutCubicEmphasized`. The card expands from the real avatar anchor; + its controls fade in after the expansion starts. Top moves the content pane + down through `AvatarNavigationBody`, keeping bottom navigation stationary. + Bottom moves the avatar upward into the card's bottom-right corner and + overlays the list immediately above navigation. Both use the Files gutter. + The floating rail beside a bottom avatar shows text only on the selected + tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar, + with the avatar inside the trailing end. Its popup expands upward with + full screen width, rounded top corners and no gap above the bar; the + avatar stays in the bar, and Search keeps its inline position. + The attached popup has no outer border or shadow, blending into the bar. + All bottom popups order navigation (including Settings), current account, + then expanded secondary accounts and Add/Manage Account controls. + The iOS shell places Upload and the top avatar on the large-title row. + Upload uses the shared `ui_fab_style` preference: Auto expands on Files + and Photos and collapses elsewhere; Mini is icon-only; Expanded always + shows the plus-and-Upload button. Upload uses the folder icon's tonal + `accentSoft` background and `accentText` foreground. Search stays below. + Upload uses the compact 32px size to match the visible top avatar. + On iOS the accent picker hides the unsupported wallpaper color option; + the accent subtitle, swatch and selection always reflect the seed color. + Upload's width, padding and label opacity interpolate over `NooMotion.base`. + A shared shell animation keeps separately mounted tab headers in sync + during Auto tab changes; reduced motion skips the transition. + Avatar navigation and its bottom satellite share the bottom bar's frost + toggle, blur and opacity, including throughout the popup animation. + The setting is labelled "Frosted glass" and also controls both media + viewer panels through `FrostedGlassContainer`. Off uses an opaque surface + without a backdrop filter; On uses the shared blur and tint opacity. + Search starts inside the rail when a bottom avatar occupies the satellite; + its row slot smoothly collapses as Search fades into that satellite while + the avatar moves into the popup. Closing reverses the same path. The card + retains hidden tabs, Settings, account switching, Add Account, and Manage + Accounts. Account controls scroll on short screens. Tap outside, tap the + popup avatar, or system Back to close. Reduced motion opens/closes without + the transition. Drawer gestures are disabled in avatar mode. Standalone + top bars outside the shell retain `showGeneralDialog` as a fallback. + `ShellAvatarButton` keeps its vertical-swipe account shortcuts in either + position. Its onTap override must have a matching tooltip/semantics label. + Also added `NooTopBar.androidTitleTrailing`: Android has no large title to put a second search row under the way iOS's `search:` slot does, so an inline search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when @@ -309,11 +378,13 @@ blocks are noted where they matter: `FileBreadcrumbRow` proved the same trail is still wanted elsewhere. - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) — the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) - for the video player's seek bar, plus a perpetually-animated + for the non-iOS video player's seek bar, plus a perpetually-animated `SeekBarPreview` wrapper used by the Settings style picker so every preview always matches the real widget exactly (same painter, just fed demo `progress`/`phase` values). Add new seek-bar presets here, not by forking the painter. + iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style + picker. Scrubbing pauses playback temporarily and restores its prior state. - Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's back button + filename, the bottom action bar, the video transport controls) all share one small hand-rolled icon-button pattern @@ -352,11 +423,11 @@ blocks are noted where they matter: ## Text/markdown viewer -`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. +`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; every text file opens read-only (plain text as-is, Markdown rendered) and follows Edit -> Save: an Edit/Preview toggle and, once dirty, a Save button live in the viewer's top bar as round icon-only `NooButton`s (`iconOnly`, 36px; a lone button is primary, with two Edit/Preview is secondary and Save primary; Save becomes a primary circle with a spinner while it saves). Offline copies are read-only with no buttons. The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. `showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus. -Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor. +Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with the same Edit/Preview toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor. `ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap. diff --git a/.gitea/scripts/ios_ci_signing.rb b/.gitea/scripts/ios_ci_signing.rb new file mode 100644 index 0000000..1a0ee18 --- /dev/null +++ b/.gitea/scripts/ios_ci_signing.rb @@ -0,0 +1,31 @@ +# CI-only: switches the Release configuration of the Runner and ShareExtension +# targets to manual signing with the App Store profiles. The committed project +# stays on Automatic signing so local development is unaffected; this edit +# happens only inside the CI checkout. Run from the repo root: +# ruby .gitea/scripts/ios_ci_signing.rb +require "xcodeproj" + +TEAM = "Q3JLTAG9PV" +# Pinned by SHA-1, not by the "Apple Distribution" name: a runner that also +# holds other same-named distribution certs (e.g. a dev Mac's login keychain) +# would otherwise let Xcode pick one the profiles don't include. Update this +# (and ios/ExportOptions.plist) when the certificate is renewed. +CERT_SHA1 = "2527806871D806E8E27221B15CA8A1938CF216F2" +PROFILES = { + "Runner" => "NooProfile", + "ShareExtension" => "NooShareSheetProfile", +}.freeze + +project = Xcodeproj::Project.open("ios/Runner.xcodeproj") +PROFILES.each do |target_name, profile| + target = project.targets.find { |t| t.name == target_name } or abort("no target #{target_name}") + target.build_configurations.select { |c| c.name == "Release" }.each do |config| + settings = config.build_settings + settings["CODE_SIGN_STYLE"] = "Manual" + settings["DEVELOPMENT_TEAM"] = TEAM + settings["CODE_SIGN_IDENTITY"] = CERT_SHA1 + settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = CERT_SHA1 + settings["PROVISIONING_PROFILE_SPECIFIER"] = profile + end +end +project.save diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 8e7a8e8..e55836b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,12 +1,13 @@ name: Build APK -# Deliberately not "on every push" - only when a release-candidate tag -# (RC1, RC2, RC2026.1, ...) is pushed. Tag the commit you want built: -# git tag RC1 && git push origin RC1 +# Deliberately not "on every push" - only when an Android release-candidate +# tag (RC-Android-1, RC-Android-2, ...) is pushed. Tag the commit you want built: +# git tag RC-Android-1 && git push origin RC-Android-1 +# iOS has its own RC-iOS-* tags (ios.yml); Release-* builds both platforms. on: push: tags: - - "RC*" + - "RC-Android-*" jobs: build: @@ -72,7 +73,7 @@ jobs: # talks to this same Gitea instance automatically via the # `github.token`/`github.server_url` context Gitea Actions # provides for compatibility. Release is named/tagged after - # whatever tag triggered this run (e.g. "RC1"). + # whatever tag triggered this run (e.g. "RC-Android-1"). uses: akkuman/gitea-release-action@v1 with: files: ${{ env.APK_PATH }} diff --git a/.gitea/workflows/ios.yml b/.gitea/workflows/ios.yml new file mode 100644 index 0000000..4f4a5a2 --- /dev/null +++ b/.gitea/workflows/ios.yml @@ -0,0 +1,103 @@ +name: Build iOS + +# Tag scheme (shared with the Android workflows): +# Release- -> release.yml (Play bundle) AND this workflow +# RC-Android- -> build.yml (APK) only +# RC-iOS- -> this workflow only +# git tag RC-iOS-1 && git push origin RC-iOS-1 +# Builds a signed .ipa and uploads it to TestFlight. Needs a Mac runner +# registered with the `macos` label (gitea-runner in host mode) with Xcode +# installed; Linux runners cannot build iOS. +on: + push: + tags: + - "Release-*" + - "RC-iOS-*" + +jobs: + build: + runs-on: macos + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Check tag matches pubspec version + # Only Release-* tags carry a version; RC-iOS-* tags are free-form. + if: startsWith(github.ref_name, 'Release-') + 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 + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Install signing certificate and profiles + # A throwaway keychain keeps the distribution key off the runner's + # login keychain; it is deleted again in the cleanup step. + env: + CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + APP_PROFILE_BASE64: ${{ secrets.IOS_APP_PROFILE_BASE64 }} + EXT_PROFILE_BASE64: ${{ secrets.IOS_EXT_PROFILE_BASE64 }} + run: | + KEYCHAIN="${{ runner.temp }}/ci-signing.keychain-db" + KEYCHAIN_PASSWORD=$(uuidgen) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + echo "$CERT_P12_BASE64" | base64 -d > "${{ runner.temp }}/dist.p12" + security import "${{ runner.temp }}/dist.p12" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + rm -f "${{ runner.temp }}/dist.p12" + # Must list 2527806871D8... (the cert the profiles embed); if it + # doesn't, IOS_DIST_CERT_P12_BASE64 holds the wrong certificate. + security find-identity -v -p codesigning "$KEYCHAIN" + + PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" + mkdir -p "$PROFILES_DIR" + echo "$APP_PROFILE_BASE64" | base64 -d > "$PROFILES_DIR/NooProfile.mobileprovision" + echo "$EXT_PROFILE_BASE64" | base64 -d > "$PROFILES_DIR/NooShareSheetProfile.mobileprovision" + + - name: Switch Release config to manual signing + run: ruby .gitea/scripts/ios_ci_signing.rb + + - name: Build signed IPA + run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist + + - name: Rename IPA to Noo-.ipa + run: | + VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) + mkdir -p release + cp build/ios/ipa/*.ipa "release/Noo-${VERSION}.ipa" + echo "IPA_PATH=release/Noo-${VERSION}.ipa" >> "$GITHUB_ENV" + + - name: Upload to TestFlight + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} + run: | + mkdir -p "$HOME/.appstoreconnect/private_keys" + echo "$ASC_KEY_P8_BASE64" | base64 -d > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + xcrun altool --upload-app -f "$IPA_PATH" -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + + - name: Cleanup signing material + if: always() + run: | + security delete-keychain "${{ runner.temp }}/ci-signing.keychain-db" || true + rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/NooProfile.mobileprovision" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/NooShareSheetProfile.mobileprovision" + rm -rf "$HOME/.appstoreconnect/private_keys" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 12646e4..28e4799 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -3,7 +3,7 @@ name: Build App Bundle # Deliberately not "on every push" - only when a real release tag (Release-1.0.0, # Release-1.2.3, ...) is pushed. Tag the commit you want built: # git tag Release-1.0.0 && git push origin Release-1.0.0 -# Unlike the RC* flow (build.yml), this builds the .aab Play Console wants, +# Unlike the RC-Android-* flow (build.yml), this builds the .aab Play Console wants, # not a sideloadable .apk. on: push: @@ -56,7 +56,7 @@ jobs: run: flutter analyze - name: Decode release keystore - # Same key as the RC* APK builds, so a Play Store upload's signature + # Same key as the RC-Android-* APK builds, so a Play Store upload's signature # matches anything sideloaded from a release build. See # android/app/build.gradle.kts. run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks" diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 0000000..346c2f2 --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,23 @@ + + + + + method + app-store-connect + teamID + Q3JLTAG9PV + signingStyle + manual + signingCertificate + 2527806871D806E8E27221B15CA8A1938CF216F2 + provisioningProfiles + + dev.ayushya.noo + NooProfile + dev.ayushya.noo.ShareExtension + NooShareSheetProfile + + uploadSymbols + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f833847..eca4693 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,15 +7,44 @@ objects = { /* Begin PBXBuildFile section */ + 0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; }; + 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; + 187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; }; + 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; + 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; + 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; + 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; + 4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; }; + 47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; }; + 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; + 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; + 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; + 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; }; + 721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; }; + 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; + 87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; }; + 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; }; + 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; + A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; + B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; }; + E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; + F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; + F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; + FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,9 +55,27 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = F9BE9B2F18B10324D7B84776; + remoteInfo = ShareExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -42,26 +89,59 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = ""; }; + 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 = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = ""; }; + 1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = ""; }; + 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = ""; }; + 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = ""; }; + 308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = ""; }; + 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; + 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = ""; }; + 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = ""; }; + 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = ""; }; + 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; + 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = ""; }; + 857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; + 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = ""; }; + AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = ""; }; + B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = ""; }; + C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 807C81744907462A25A6C1F8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -73,14 +153,62 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2B7F16B0F0EA2C5A842ED860 /* iOS */ = { + isa = PBXGroup; + children = ( + 0AE6F681A3100A461B4A5318 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */, ); path = RunnerTests; sourceTree = ""; }; + 3664F08C433ECEB00D1C4BDB /* ShareExtension */ = { + isa = PBXGroup; + children = ( + 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */, + 49213989F0FA1B83B0B8A523 /* Info.plist */, + 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, + 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */, + 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */, + ); + name = ShareExtension; + path = ShareExtension; + sourceTree = ""; + }; + 5055E53DC55C0BCABD5F04F4 /* Native */ = { + isa = PBXGroup; + children = ( + 8064CC1652B8F0A44E371638 /* TransferManager.swift */, + 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, + DDC89BA8A42212F55851CBA1 /* SyncEngine */, + ); + name = Native; + path = Native; + sourceTree = ""; + }; + 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 = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -100,6 +228,9 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 5DF0A9032A8500B67D6C89AE /* Shared */, + 3664F08C433ECEB00D1C4BDB /* ShareExtension */, + A5B80AB7F4AB17F90B745D0D /* Frameworks */, ); sourceTree = ""; }; @@ -108,6 +239,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 405A5408F4071B4D12F08B21 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -124,10 +256,35 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 5055E53DC55C0BCABD5F04F4 /* Native */, + 857A0E7637776A71AFBB0614 /* Runner.entitlements */, ); path = Runner; sourceTree = ""; }; + A5B80AB7F4AB17F90B745D0D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2B7F16B0F0EA2C5A842ED860 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; + DDC89BA8A42212F55851CBA1 /* SyncEngine */ = { + isa = PBXGroup; + children = ( + AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */, + A39E02713460B6C7875F3278 /* SyncStore.swift */, + 308A73EC19D196226AB395DC /* DavSyncClient.swift */, + 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */, + C028B2E4E2874BB74498EABD /* SyncRunner.swift */, + 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */, + 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */, + ); + name = SyncEngine; + path = SyncEngine; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -152,6 +309,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -162,6 +320,7 @@ buildRules = ( ); dependencies = ( + 6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -171,6 +330,23 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F9BE9B2F18B10324D7B84776 /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 3CD40C8747E650E6F817CDDB /* Sources */, + 807C81744907462A25A6C1F8 /* Frameworks */, + 4EDBC7D61EA0C0974DA27BD6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareExtension; + productName = ShareExtension; + productReference = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -209,6 +385,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + F9BE9B2F18B10324D7B84776 /* ShareExtension */, ); }; /* End PBXProject section */ @@ -221,6 +398,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4EDBC7D61EA0C0974DA27BD6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -274,6 +458,24 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + 804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3CD40C8747E650E6F817CDDB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */, + 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */, + F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */, + 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */, + 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */, + 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */, + 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, + 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, + A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */, + 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -284,6 +486,22 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */, + 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */, + F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */, + 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */, + 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */, + E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */, + 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, + A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, + 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */, + 721B9C528F23F144966483E0 /* SyncModels.swift in Sources */, + 4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */, + 0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */, + 87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */, + 47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */, + B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */, + 187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -295,6 +513,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ShareExtension; + target = F9BE9B2F18B10324D7B84776 /* ShareExtension */; + targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -360,7 +584,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -375,6 +599,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -437,6 +662,89 @@ }; name = Profile; }; + 3B057BF66761E30DEABF8B30 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 6BF252CEA7A55024A2F58A36 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 6FB3F72FB5A94DE254FF4FBF /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -486,7 +794,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -537,7 +845,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -554,6 +862,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -576,6 +885,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -624,6 +934,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6BF252CEA7A55024A2F58A36 /* Release */, + 6FB3F72FB5A94DE254FF4FBF /* Debug */, + 3B057BF66761E30DEABF8B30 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..afc9315 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -7,10 +8,52 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + TransferManager.shared.reconnect() + // BGTask handlers have to be registered before launch finishes. + SyncCoordinator.shared.registerBackgroundTasks() + SyncCoordinator.shared.registerNotificationCategories() + let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) + UNUserNotificationCenter.current().delegate = self + SyncCoordinator.shared.scheduleBackgroundWork() + return launched + } + + /// Taps on a sync-conflict notification's "Keep local" / "Use server". + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return } + super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler) + } + + /// A transfer summary ("Downloaded 1 file") usually finishes while the app + /// is open; without this iOS delivers it silently to Notification Center + /// instead of showing the banner. + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound]) + } + + override func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier { + TransferManager.shared.backgroundCompletionHandler = completionHandler + } else { + super.application( + application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler) + } } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger()) } } diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..ecc64da 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..0e50aaa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..b5b5172 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..9e90cdb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..b7d6088 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..2d0050f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..a4a07ff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..b5b5172 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..c09e8a8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..a54e998 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..a54e998 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..0b64712 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..0649019 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..68d6cae 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..0aad66e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 29e8c6b..3d447d4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,6 +53,26 @@ LaunchScreen UIMainStoryboardFile Main + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + NooKeychainAccessGroup + $(AppIdentifierPrefix)dev.ayushya.noo.shared + BGTaskSchedulerPermittedIdentifiers + + dev.ayushya.noo.sync.refresh + dev.ayushya.noo.sync.processing + + UIBackgroundModes + + fetch + processing + + NSFaceIDUsageDescription + Noo uses Face ID to unlock the app and to protect hidden files. + NSPhotoLibraryUsageDescription + Noo needs access to your photos and videos so you can choose which ones to upload to your server. UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -66,5 +86,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + ITSAppUsesNonExemptEncryption + diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift new file mode 100644 index 0000000..762d74d --- /dev/null +++ b/ios/Runner/Native/NativeServices.swift @@ -0,0 +1,295 @@ +import Flutter +import Foundation +import UIKit + +/// Holds the one native-side listener of an `EventChannel` so Swift code can +/// push events to Dart whenever they happen. +final class SinkStreamHandler: NSObject, FlutterStreamHandler { + var sink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } +} + +/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to +/// (Android's lives in `MainActivity.kt`). Upload, download and share (the +/// receiving half of the Share Extension) are implemented so far; the rest +/// still throw `MissingPluginException`, which the Dart side treats as +/// "unavailable" (`lib/services/native_channel.dart`). +enum NativeServices { + private static let uploadStatus = SinkStreamHandler() + + static func register(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) } + handle(call, result) { args in + let files = try parseJSONList(args["files"]) + try TransferManager.shared.startUpload( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + files: files.compactMap { entry in + guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else { + return nil + } + return UploadFile(uri: uri, name: name) + }, + remoteFolder: (args["remoteFolder"] as? String) ?? "/" + ) + } + } + + FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) } + handle(call, result) { args in + let items = try parseJSONList(args["files"]) + try TransferManager.shared.startDownload( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + items: items.compactMap { entry in + guard let path = entry["path"] as? String, let name = entry["name"] as? String else { + return nil + } + return (path: path, name: name) + } + ) + } + } + + FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger) + .setStreamHandler(uploadStatus) + TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in + uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed]) + } + TransferManager.shared.reconnect() + registerShare(messenger: messenger) + registerShareAccount(messenger: messenger) + registerSync(messenger: messenger) + } + + // MARK: - Device sync + + /// Sends the current sync status the moment Dart starts listening, then + /// every change - same as Android's `SyncStatusBus.subscribe`. + private final class SyncStatusStream: NSObject, FlutterStreamHandler { + var sink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + sink = events + let coordinator = SyncCoordinator.shared + events(coordinator.statusMap(coordinator.bus.snapshot())) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } + } + + private static let syncStatusStream = SyncStatusStream() + /// Serial, so status events reach Dart in the order they happened even + /// though building each one reads the state from disk. + private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility) + + /// `sync_service`: the same methods Android's `MainActivity.kt` serves, + /// backed by `SyncCoordinator`/`SyncRunner`. + private static func registerSync(messenger: FlutterBinaryMessenger) { + let coordinator = SyncCoordinator.shared + + FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + let args = call.arguments as? [String: Any] ?? [:] + switch call.method { + case "reschedule": + handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) } + case "cancel": + // `forget` (default true) = signed out / removed; false = only the + // background job is off - credentials stay for manual runs and + // conflict actions. + handle(call, result) { _ in + coordinator.cancel( + accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true) + } + case "syncNow": + handle(call, result) { _ in + let force = args["force"] as? Bool ?? true + coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool) + } + case "getSyncStatus": + let accountId = args["accountId"] as? String + syncStatusQueue.async { + let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId) + DispatchQueue.main.async { result(map) } + } + case "resolveConflict": + handle(call, result) { _ in + let conflict = SyncConflict( + accountId: try string(args, "accountId"), fileId: try string(args, "fileId"), + remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"), + name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent) + let creds = SyncCredentials( + serverUrl: try string(args, "serverUrl"), username: try string(args, "username"), + authHeader: try string(args, "authHeader")) + let resolution = try string(args, "resolution") + // Fire and forget, like Android: the status stream reports the result. + Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) } + } + case "removeLocalSync": + guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else { + return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil)) + } + Task { + await coordinator.removeLocalSync(accountId: accountId, path: path) + await MainActor.run { result(nil) } + } + default: + result(FlutterMethodNotImplemented) + } + } + + FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger) + .setStreamHandler(syncStatusStream) + coordinator.bus.onChange = { status in + syncStatusQueue.async { + let map = coordinator.statusMap(status) + DispatchQueue.main.async { syncStatusStream.sink?(map) } + } + } + } + + /// The account credentials + settings every sync call carries, as a config. + private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig { + var folders: [String] = [] + if let json = args["folders"] as? String, let data = json.data(using: .utf8), + let list = try? JSONSerialization.jsonObject(with: data) as? [String] + { + folders = list + } + return SyncAccountConfig( + accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), authHeader: try string(args, "authHeader"), + folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true, + intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true) + } + + // MARK: - Account for the Share Extension + + /// `share_account`: Dart hands native every account that can upload, which + /// one is active, and the app-lock settings, so the Share Extension - a + /// separate process with no Flutter engine - can list folders and upload as + /// them. Stored in the shared Keychain group. + private static func registerShareAccount(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + switch call.method { + case "setAccounts": + handle(call, result) { args in + guard let json = args["accounts"] as? String, + let data = json.data(using: .utf8), + let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data) + else { throw TransferError(message: "Bad accounts payload.") } + try SharedAccountStore.save(accounts) + NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none") + } + case "clearAccount": + SharedAccountStore.clear() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + // MARK: - Share Extension inbox + + private static let shareEvents = SinkStreamHandler() + + /// `share_intent`: files the Share Extension left in the App Group inbox. + /// Cold start asks once (`getInitialShare`); a share that arrives while the + /// app is running is pushed when it next becomes active (the extension + /// can't signal the app any other way). + private static func registerShare(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) } + result(consumeShared().map(encode)) + } + FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger) + .setStreamHandler(shareEvents) + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { _ in + TransferManager.shared.deliverExtensionUploads() + guard let sink = shareEvents.sink else { return } + let items = consumeShared() + if !items.isEmpty { sink(items.map(encode)) } + } + } + + private static func consumeShared() -> [SharedItem] { + guard let root = AppGroup.containerURL else { return [] } + return SharedInbox.consume(in: root) + } + + private static func encode(_ item: SharedItem) -> [String: Any] { + [ + "uri": URL(fileURLWithPath: item.path).absoluteString, + "name": item.name, + "mimeType": item.mimeType ?? NSNull(), + "size": item.size, + ] + } + + // MARK: - Argument helpers + + /// Runs [work] with the call's argument map, turning a thrown error into + /// the `FlutterError` Dart's `catch` shows the user. + private static func handle( + _ call: FlutterMethodCall, + _ result: @escaping FlutterResult, + work: ([String: Any]) throws -> Void + ) { + guard let args = call.arguments as? [String: Any] else { + return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil)) + } + do { + try work(args) + result(nil) + } catch { + result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil)) + } + } + + private static func string(_ args: [String: Any], _ key: String) throws -> String { + guard let value = args[key] as? String, !value.isEmpty else { + throw TransferError(message: "Missing \(key).") + } + return value + } + + /// The Dart services pass their file list as one JSON string. + private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] { + guard let json = value as? String, + let data = json.data(using: .utf8), + let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + throw TransferError(message: "Missing file list.") + } + return list + } +} diff --git a/ios/Runner/Native/SyncEngine/DavSyncClient.swift b/ios/Runner/Native/SyncEngine/DavSyncClient.swift new file mode 100644 index 0000000..5927967 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/DavSyncClient.swift @@ -0,0 +1,294 @@ +import Foundation + +/// Credentials for one account's WebDAV root. +struct SyncCredentials { + let serverUrl: String + let username: String + let authHeader: String +} + +/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item), +/// a recursive walk, and GET/PUT of a single file. Foreground +/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a +/// background session, so a run can walk, diff and decide in one go. +/// +/// Authenticates by each request's own `Authorization` header only (no cookies, +/// no credential cache): two accounts can share a server, and one account's +/// session must never answer for another. +final class DavSyncClient { + private let session: URLSession + + init(session: URLSession = DavSyncClient.makeSession()) { + self.session = session + } + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 15 * 60 + return URLSession(configuration: config) + } + + // MARK: - PROPFIND + + private static let propfindBody = """ + + + + + + + + + + + """ + + /// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the + /// folder is genuinely gone) is an empty list; anything else that goes wrong + /// throws `SyncRemoteUnavailable`. + func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] { + try await propfind(creds, path: path, depth: "1", skipSelf: true) + } + + /// Depth-0 PROPFIND of [path] itself - nil on a clean 404. + func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? { + try await propfind(creds, path: path, depth: "0", skipSelf: false).first + } + + private func propfind( + _ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool + ) async throws -> [SyncRemoteEntry] { + var clean = path.trimmingCharacters(in: .whitespaces) + if !clean.hasPrefix("/") { clean = "/" + clean } + guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean) + else { throw SyncRemoteUnavailable(message: "Invalid server address.") } + // Collections are addressed with a trailing slash. + if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed } + + var request = URLRequest(url: url) + request.httpMethod = "PROPFIND" + request.setValue(depth, forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = Self.propfindBody.data(using: .utf8) + + let data: Data + let status: Int + do { + let (body, response) = try await session.data(for: request) + data = body + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + } catch { + try Self.rethrowIfCancelled(error) + throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)") + } + if status == 404 { return [] } // genuinely gone + guard status == 207 else { + throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)") + } + return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf) + } + + /// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat + /// manifest. Throws if *any* level fails - a partial manifest would look + /// like the missing part had been deleted on the server. + func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] { + var result: [SyncRemoteEntry] = [] + var queue = [root] + var index = 0 + while index < queue.count { + try Task.checkCancellation() + let children = try await propfindChildren(creds, path: queue[index]) + index += 1 + for child in children { + result.append(child) + if child.isFolder { queue.append(child.path) } + } + } + return result + } + + // MARK: - GET / PUT + + /// Downloads [remotePath] to [destination]. Written to a temp file first and + /// moved into place, so an interrupted download never leaves a truncated + /// file where a good one used to be. False on any failure. + func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool { + guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath) + else { return false } + var request = URLRequest(url: url) + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + do { + let (temp, response) = try await session.download(for: request) + guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else { + try? FileManager.default.removeItem(at: temp) + return false + } + try Task.checkCancellation() + let fm = FileManager.default + try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + var isDirectory: ObjCBool = false + if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) { + // Never replace a folder with a file, and replace a file atomically: + // the old copy stays until the new one is fully in place. + guard !isDirectory.boolValue else { + try? fm.removeItem(at: temp) + return false + } + _ = try fm.replaceItemAt(destination, withItemAt: temp) + } else { + try fm.moveItem(at: temp, to: destination) + } + return true + } catch { + try Self.rethrowIfCancelled(error) + return false + } + } + + /// PUTs [source] to [remotePath]. False on any failure. + func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool { + guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath) + else { return false } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + do { + let (_, response) = try await session.upload(for: request, fromFile: source) + guard let status = (response as? HTTPURLResponse)?.statusCode else { return false } + return (200..<300).contains(status) + } catch { + try Self.rethrowIfCancelled(error) + return false + } + } + + /// A cancelled run must stop, not be mistaken for a flaky network. + private static func rethrowIfCancelled(_ error: Error) throws { + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw CancellationError() + } + } +} + +/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's +/// unit-tested. +enum DavSyncParser { + private struct Raw { + var href = "" + var etag = "" + var modified = "" + var length = "" + var fileId = "" + var isCollection = false + } + + private final class Delegate: NSObject, XMLParserDelegate { + var responses: [Raw] = [] + var sawMultistatus = false + private var current: Raw? + private var text = "" + + func parser( + _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] + ) { + text = "" + if elementName == "multistatus" { sawMultistatus = true } + if elementName == "response" { current = Raw() } + if elementName == "collection" { current?.isCollection = true } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + text += string + } + + func parser( + _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String? + ) { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + switch elementName { + case "href": current?.href = value + case "getetag": current?.etag = value + case "getlastmodified": current?.modified = value + case "getcontentlength": current?.length = value + case "fileid": current?.fileId = value + case "response": + if let current { responses.append(current) } + current = nil + default: break + } + text = "" + } + } + + private static let httpDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return f + }() + + /// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for + /// anything that isn't a complete, plausible answer - malformed or + /// truncated XML, no `multistatus` envelope, no responses at all (a 207 + /// always has at least the requested resource), or an href that isn't + /// under this account's files root or tries to climb out of it. A partial + /// manifest would look like the missing part was deleted on the server + /// (and an empty one like the whole path was), so the caller must treat + /// those as "couldn't reach the server", never as data. + static func entries( + from xml: Data, username: String, requestedPath: String, skipSelf: Bool + ) throws -> [SyncRemoteEntry] { + let delegate = Delegate() + let parser = XMLParser(data: xml) + parser.shouldProcessNamespaces = true + parser.delegate = delegate + guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else { + throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer") + } + + let marker = "/remote.php/dav/files/\(username)" + let selfPath = trimTrailingSlashes(requestedPath) + + var entries: [SyncRemoteEntry] = [] + for raw in delegate.responses { + let decoded = raw.href.removingPercentEncoding ?? raw.href + guard let range = decoded.range(of: marker) else { + throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root") + } + let hrefPath = String(decoded[range.upperBound...]) + // A well-formed path continues the marker at a component boundary and + // never contains a dot component. + guard hrefPath.isEmpty || hrefPath.hasPrefix("/"), + !hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." }) + else { + throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path") + } + let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath) + if skipSelf && normalized == selfPath { continue } // the folder itself, not a child + entries.append( + SyncRemoteEntry( + path: normalized.isEmpty ? "/" : normalized, + fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId, + etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")), + lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0, + size: Int64(raw.length) ?? 0, + isFolder: raw.isCollection)) + } + return entries + } + + private static func trimTrailingSlashes(_ path: String) -> String { + var p = path + while p.hasSuffix("/") { p.removeLast() } + return p + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncConfigStore.swift b/ios/Runner/Native/SyncEngine/SyncConfigStore.swift new file mode 100644 index 0000000..bb5b0ae --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncConfigStore.swift @@ -0,0 +1,120 @@ +import Foundation +import Security + +/// Every account's sync settings and credentials, kept in the Keychain (the +/// auth header is a secret) as one JSON item. A background run - which may +/// start while the app isn't running - reads these to know what to sync and +/// as whom, and a conflict notification's action looks the account up here +/// instead of carrying credentials in the notification itself. +/// +/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background +/// task can fire with the phone locked. +/// +/// All accounts live in one item, so a write must never be built on a +/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and +/// say so) rather than save a map holding only one account, and an update +/// replaces the item in place instead of deleting it first. +final class SyncConfigStore { + private struct KeychainError: Error { let status: OSStatus } + + private let service: String + private let account = "configs" + private let lock = NSLock() + + init(service: String = "dev.ayushya.noo.sync-configs") { + self.service = service + } + + /// Empty if there are none - or if the item can't be read, in which case + /// nothing runs in the background until it can. + func all() -> [SyncAccountConfig] { + lock.lock() + defer { lock.unlock() } + return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId } + } + + func config(for accountId: String) -> SyncAccountConfig? { + lock.lock() + defer { lock.unlock() } + return (try? load())?[accountId] + } + + /// False if it couldn't be stored (the previous contents are untouched). + @discardableResult + func upsert(_ config: SyncAccountConfig) -> Bool { + lock.lock() + defer { lock.unlock() } + do { + var configs = try load() + configs[config.accountId] = config + try save(configs) + return true + } catch { + NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error)) + return false + } + } + + @discardableResult + func remove(accountId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + do { + var configs = try load() + guard configs.removeValue(forKey: accountId) != nil else { return true } + try save(configs) + return true + } catch { + NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error)) + return false + } + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + SecItemDelete(baseQuery() as CFDictionary) + } + + // MARK: - Keychain + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + /// `[:]` only when nothing is stored yet; any other failure throws. + private func load() throws -> [String: SyncAccountConfig] { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [:] } + guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) } + return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data) + } + + private func save(_ configs: [String: SyncAccountConfig]) throws { + guard !configs.isEmpty else { + let status = SecItemDelete(baseQuery() as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) } + return + } + let data = try JSONEncoder().encode(configs) // before touching the item + let status = SecItemUpdate( + baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let added = SecItemAdd(query as CFDictionary, nil) + if added != errSecSuccess { throw KeychainError(status: added) } + } else if status != errSecSuccess { + throw KeychainError(status: status) + } + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift new file mode 100644 index 0000000..26cb52d --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift @@ -0,0 +1,454 @@ +import BackgroundTasks +import Network +import UIKit +import UserNotifications + +/// Whether the network currently allows a sync run: any connection normally, +/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) - +/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint, +/// which a `BGTask` doesn't offer. +enum NetworkGate { + static func allows(wifiOnly: Bool) async -> Bool { + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate") + return await withCheckedContinuation { continuation in + var answered = false // only touched on `queue` + monitor.pathUpdateHandler = { path in + guard !answered else { return } + answered = true + monitor.cancel() + let connected = path.status == .satisfied + continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive)) + } + monitor.start(queue: queue) + } + } +} + +/// Runs an action at most once - for completing a `BGTask`, which must be +/// told it's done exactly one time whether the work finished or the system's +/// time ran out first. +final class OnceGate { + private let lock = NSLock() + private var done = false + private let action: (Bool) -> Void + + init(_ action: @escaping (Bool) -> Void) { + self.action = action + } + + func finish(_ success: Bool) { + lock.lock() + if done { + lock.unlock() + return + } + done = true + lock.unlock() + action(success) + } +} + +/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side +/// asks for, the periodic background runs iOS grants through +/// `BGTaskScheduler`, conflict resolution, and the notifications around them. +/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s +/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`. +/// +/// Every run - foreground or background - goes through `startRun`, so one +/// per-account record knows what's running, "Sync now" can supersede it and a +/// quiet check can leave it alone, and signing out or removing a path can +/// cancel it. Runs are also serialised against each other (and against +/// removal) by one mutex, and carry the account's *generation*: cancelling or +/// removing bumps it, so a run that was only queued behind another never +/// starts for an account that's gone in the meantime. +/// +/// iOS decides *when* background work runs (typically while charging, on +/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the +/// earliest it may start, not a schedule, and a run is cut off when the +/// system's time budget ends (the runner saves its state as it goes). +final class SyncCoordinator { + static let shared = SyncCoordinator() + + static let refreshTaskId = "dev.ayushya.noo.sync.refresh" + static let processingTaskId = "dev.ayushya.noo.sync.processing" + + static let conflictCategory = "dev.ayushya.noo.sync.conflict" + static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal" + static let useServerAction = "dev.ayushya.noo.sync.useServer" + + let store: SyncStore + let bus: SyncStatusBus + let configs: SyncConfigStore + private let runner: SyncRunner + + /// One run at a time, app-wide (see `AsyncMutex`). + private let mutex = AsyncMutex() + private let lock = NSLock() + private var running: [String: (token: UUID, task: Task)] = [:] + private var generations: [String: Int] = [:] + + init( + store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(), + configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient() + ) { + self.store = store + self.bus = bus + self.configs = configs + self.runner = SyncRunner(client: client, store: store, bus: bus) + } + + // MARK: - Settings from Dart + + /// Brings the background job in line with [config] (called whenever the + /// synced paths, account or network setting changes). A config with no + /// folders means this account has nothing to sync. + func reschedule(_ config: SyncAccountConfig) { + if config.folders.isEmpty { + cancel(accountId: config.accountId, forget: true) + return + } + configs.upsert(config) + scheduleBackgroundWork() + } + + /// [forget] true: the account was removed or signed out - stop its run and + /// drop its settings and credentials. [forget] false: only background sync + /// was turned off (manual refresh is still possible) - the credentials stay, + /// since a conflict notification's actions and the next "Sync now" need + /// them, and a run in progress is left to finish. + func cancel(accountId: String, forget: Bool = true) { + if forget { + cancelRun(accountId: accountId, invalidateQueued: true) + configs.remove(accountId: accountId) + } else if var config = configs.config(for: accountId) { + config.intervalMinutes = nil + configs.upsert(config) + } + scheduleBackgroundWork() + } + + /// A one-off run. [force] is an explicit "Sync now" / newly added path: + /// a full walk, a summary notification, and it supersedes any run already + /// going for this account; without it (pull-to-refresh, foreground timer, + /// app resume) it's a quiet check that never interrupts a run in progress. + /// [wifiOnly] non-nil means "respect the Wi-Fi-only setting". + func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) { + if config.folders.isEmpty { return } + // Keep the background settings this call doesn't carry. + var stored = config + if let existing = configs.config(for: config.accountId) { + stored.intervalMinutes = existing.intervalMinutes + stored.wifiOnly = existing.wifiOnly + } + configs.upsert(stored) + startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly) + } + + /// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that + /// are only waiting their turn from ever starting. + private func cancelRun(accountId: String, invalidateQueued: Bool) { + lock.lock() + if invalidateQueued { generations[accountId, default: 0] += 1 } + running[accountId]?.task.cancel() + lock.unlock() + } + + private func generation(of accountId: String) -> Int { + lock.lock() + defer { lock.unlock() } + return generations[accountId] ?? 0 + } + + /// Starts a tracked run, or - for a non-forced one when this account is + /// already running - returns nil and leaves the current run alone. + @discardableResult + private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task? { + lock.lock() + if let existing = running[config.accountId] { + if force { + existing.task.cancel() + } else { + lock.unlock() + return nil + } + } + let token = UUID() + let generation = generations[config.accountId] ?? 0 + let task = Task { [weak self] in + await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation) + self?.finished(accountId: config.accountId, token: token) + } + running[config.accountId] = (token, task) + lock.unlock() + return task + } + + private func finished(accountId: String, token: UUID) { + lock.lock() + if running[accountId]?.token == token { running[accountId] = nil } + lock.unlock() + } + + /// Turning sync off for [path]: stop this account's run first and do the + /// removal under the same mutex as runs - a run still holding the old + /// state would otherwise write it back over the removal (and a transfer + /// finishing after it would leave a file the state doesn't know about). + func removeLocalSync(accountId: String, path: String) async { + cancelRun(accountId: accountId, invalidateQueued: true) + _ = try? await mutex.withLock { + store.removeLocalSync(accountId: accountId, path: path) + } + } + + /// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride] + /// is the account Dart is asking about: the bus only learns an account once + /// a run has happened in this process, so on a fresh start the durable + /// per-file synced state would otherwise be reported as empty. + func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] { + let accountId = accountIdOverride ?? status.accountId + // Live syncing state belongs to whichever account last synced. + let busMatches = status.accountId == nil || status.accountId == accountId + let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? [] + let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? [] + return [ + "accountId": accountId as Any, + "syncing": busMatches && status.syncing, + "syncingFileIds": busMatches ? Array(status.syncingFileIds) : [], + "syncedFileIds": synced, + "missingRoots": missing, + // Conflicts are per account (a file id is only unique within one). + "conflicts": status.conflicts.filter { $0.accountId == accountId }.map { + [ + "accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath, + "relPath": $0.relPath, "name": $0.name, + ] + }, + ] + } + + // MARK: - Running + + /// One account's pass, serialised against every other run. + private func execute( + _ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int + ) async { + // A few extra seconds if the app is backgrounded mid-run; if even those + // run out, stop this account's run so it saves and ends cleanly. + let box = BackgroundTaskBox() + box.id = await MainActor.run { + UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in + self?.cancelRun(accountId: config.accountId, invalidateQueued: false) + UIApplication.shared.endBackgroundTask(box.id) + } + } + defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } } + + do { + try await mutex.withLock { + try Task.checkCancellation() + // Gone (signed out / removed) while this was queued? + guard self.generation(of: config.accountId) == generation, + self.configs.config(for: config.accountId) != nil + else { return } + if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return } + let summary = await runner.run(config, force: force) + if summary.changedAnything && (force || config.notify) { + SyncNotifications.summary(summary, username: config.username, accountId: config.accountId) + } + SyncNotifications.conflicts(summary.conflicts, username: config.username) + } + } catch { + // Cancelled while waiting for its turn - nothing ran. + } + } + + /// Resolves a conflict from a notification action, using the stored + /// credentials for its account. + func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool { + (try? await mutex.withLock { + await runner.resolveConflict(conflict, resolution: resolution, creds: creds) + }) ?? false + } + + // MARK: - Background tasks + + /// Registers the two `BGTask` handlers. Must run before the app finishes + /// launching (see `AppDelegate`). + func registerBackgroundTasks() { + for id in [Self.refreshTaskId, Self.processingTaskId] { + BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in + self?.handleBackground(task) + } + } + } + + /// Asks iOS for the next background run: a short app-refresh slot and a + /// longer processing slot (the system picks when, e.g. overnight on power). + /// Nothing is requested when no account has background sync turned on. + func scheduleBackgroundWork() { + let scheduler = BGTaskScheduler.shared + let due = backgroundConfigs() + guard let minutes = due.compactMap(\.intervalMinutes).min() else { + scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId) + scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId) + return + } + // Same floor as Android's periodic work. + let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60)) + + let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId) + refresh.earliestBeginDate = earliest + let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId) + processing.earliestBeginDate = earliest + processing.requiresNetworkConnectivity = true + processing.requiresExternalPower = false + for request in [refresh, processing] as [BGTaskRequest] { + do { try scheduler.submit(request) } catch { + NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error)) + } + } + } + + private func backgroundConfigs() -> [SyncAccountConfig] { + configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty } + } + + /// A background slot: runs every account that has background sync on, each + /// through the normal tracked path (so it can be cancelled by sign-out, and + /// a run already going is left alone). The task is reported complete + /// exactly once - when the work ends or when the system's time does. + private func handleBackground(_ task: BGTask) { + scheduleBackgroundWork() // line up the next one before this one runs + task.expirationHandler = startBackgroundSlot { task.setTaskCompleted(success: $0) } + } + + /// Runs a background slot and returns its expiration handler. + @discardableResult + func startBackgroundSlot(completion: @escaping (Bool) -> Void) -> () -> Void { + let gate = OnceGate(completion) + let accountIds = backgroundConfigs().map(\.accountId) + let work = Task { [weak self] in + guard let self else { return gate.finish(false) } + for config in self.backgroundConfigs() { + if Task.isCancelled { break } + await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value + } + gate.finish(!Task.isCancelled) + } + // The system's time is up: stop the work and the runs it started (each + // saves what it has) and report. + return { [weak self] in + work.cancel() + for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) } + gate.finish(false) + } + } + + // MARK: - Notifications + + /// Registers the conflict notification's actions. Call once at launch. + func registerNotificationCategories() { + let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: []) + let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: []) + let category = UNNotificationCategory( + identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: []) + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + /// Handles a tap on a conflict notification's action. Returns whether the + /// response was ours (so `AppDelegate` knows not to pass it on). + @MainActor + func handleNotificationResponse( + _ response: UNNotificationResponse, completion: @escaping () -> Void + ) -> Bool { + let content = response.notification.request.content + guard content.categoryIdentifier == Self.conflictCategory else { return false } + let resolution: String + switch response.actionIdentifier { + case Self.keepLocalAction: resolution = "local" + case Self.useServerAction: resolution = "server" + default: + completion() // plain tap: just opens the app + return true + } + let info = content.userInfo + guard let accountId = info["accountId"] as? String, + let fileId = info["fileId"] as? String, + let remotePath = info["remotePath"] as? String, + let relPath = info["relPath"] as? String, + let config = configs.config(for: accountId) + else { + completion() + return true + } + let conflict = SyncConflict( + accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath, + name: (relPath as NSString).lastPathComponent) + let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + let box = BackgroundTaskBox() + box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") { + completion() + UIApplication.shared.endBackgroundTask(box.id) + } + Task { + let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds) + if ok { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [response.notification.request.identifier]) + } + completion() + await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) } + } + return true + } +} + +/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created +/// before the identifier exists) can end the task it belongs to. +final class BackgroundTaskBox { + var id: UIBackgroundTaskIdentifier = .invalid +} + +extension SyncAccountConfig { + func withNotify(_ notify: Bool) -> SyncAccountConfig { + var copy = self + copy.notify = notify + return copy + } +} + +/// The local notifications sync posts: a summary of what changed, and one per +/// conflict (which always posts - it needs a decision) with Keep local / Use +/// server actions. +enum SyncNotifications { + static func summary(_ summary: SyncRunSummary, username: String, accountId: String) { + var parts: [String] = [] + if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") } + if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") } + if summary.deleted > 0 { parts.append("\(summary.deleted) removed") } + let content = UNMutableNotificationContent() + content.title = "Noo sync · \(username)" + content.body = parts.joined(separator: ", ") + // One per account, so a newer summary replaces an older one in place. + post(content, id: "sync.summary.\(accountId)") + } + + static func conflicts(_ conflicts: [SyncConflict], username: String) { + for conflict in conflicts { + let content = UNMutableNotificationContent() + content.title = "Sync conflict: \(conflict.name)" + content.body = "\(username) - changed both on this device and on the server." + content.categoryIdentifier = SyncCoordinator.conflictCategory + content.userInfo = [ + "accountId": conflict.accountId, "fileId": conflict.fileId, + "remotePath": conflict.remotePath, "relPath": conflict.relPath, + ] + post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)") + } + } + + private static func post(_ content: UNMutableNotificationContent, id: String) { + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncDiff.swift b/ios/Runner/Native/SyncEngine/SyncDiff.swift new file mode 100644 index 0000000..3ae8621 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncDiff.swift @@ -0,0 +1,186 @@ +import Foundation + +/// A local file's size and modification time (ms), the pair sync compares +/// against what it recorded. +struct LocalStat: Equatable { + let size: Int64 + let mtimeMs: Int64 +} + +enum LocalFS { + /// nil if [url] doesn't exist. + static func stat(_ url: URL) -> LocalStat? { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil } + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0 + return LocalStat(size: size, mtimeMs: mtime) + } + + static func relative(_ path: String) -> String { + path.hasPrefix("/") ? String(path.dropFirst()) : path + } + + static func trimTrailingSlashes(_ path: String) -> String { + var p = path + while p.hasSuffix("/") { p.removeLast() } + return p + } + + /// [rel] under [root], or nil if it isn't safely inside it: empty, with a + /// `.`/`..` component, or resolving outside [root]. Every path that came + /// from the server goes through this before anything is read, written or + /// deleted, so a hostile or buggy server can't point sync at other files. + static func resolve(_ rel: String, in root: URL) -> URL? { + let parts = rel.split(separator: "/", omittingEmptySubsequences: true) + guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil } + let url = root.appendingPathComponent(rel) + let rootPath = root.standardizedFileURL.path + guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil } + return url + } +} + +/// The decisions of a sync pass, kept free of networking so they can be +/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its +/// helpers - the same rules, so a device behaves the same on either platform. +enum SyncDiff { + /// Diffs one synced path's remote manifest against the recorded state. + /// Callers own actually doing the GET/PUT/delete and updating state. + /// + /// - A file with no recorded state is simply pulled. + /// - Server changed and device changed -> conflict (never silently overwrite). + /// - Only the server changed -> download. Only the device changed -> upload. + /// - Recorded but missing on the device -> pull it back (it's inside a path + /// the user asked to sync, so a vanished copy is repaired, not propagated). + /// - Recorded for this path but absent from a *successful* listing -> + /// deleted on the server, so the local copy goes too. (An unreachable + /// server never gets here - the caller skips the path instead.) + static func diffFolder( + entries: [SyncRemoteEntry], + state: [String: SyncFileState], + priorFolderFileIds: Set, + syncRoot: URL + ) -> [SyncAction] { + var actions: [SyncAction] = [] + var seen = Set() + + for entry in entries where !entry.isFolder { + seen.insert(entry.fileId) + let rel = LocalFS.relative(entry.path) + let local = LocalFS.stat(syncRoot.appendingPathComponent(rel)) + + guard let prior = state[entry.fileId] else { + actions.append(.download(entry)) + continue + } + let serverChanged = entry.etag != prior.etag + let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false + + if local == nil { + actions.append(.download(entry)) + } else if serverChanged && localChanged { + actions.append(.conflict(entry, relPath: rel)) + } else if serverChanged { + actions.append(.download(entry)) + } else if localChanged { + actions.append(.upload(relPath: rel, fileId: entry.fileId)) + } + } + + for fileId in priorFolderFileIds where !seen.contains(fileId) { + guard let prior = state[fileId] else { continue } + actions.append(.delete(relPath: prior.relPath, fileId: fileId)) + } + return actions + } + + /// The recorded files that belong to the synced [path]: everything for the + /// root ("/"), else the file itself or anything under it - matched on a + /// path-component boundary, so "/Docs" never claims "/Docs2" or + /// "/Documents". + static func priorFileIds(state: [String: SyncFileState], path: String) -> Set { + let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path)) + return Set( + state.filter { + rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/") + }.keys) + } + + /// Local paths (as spelled by the server) that more than one remote entry + /// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two + /// canonically-equivalent Unicode spellings. Downloading either would + /// overwrite the other, so callers skip them. + static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set { + var byKey: [String: [String]] = [:] + for entry in entries { + let rel = LocalFS.relative(entry.path) + byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel) + } + // Compared as raw bytes: Swift's own `String` equality already treats + // canonically equivalent spellings as the same string, which is exactly + // the distinction the server preserves and the disk may not. + return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 }) + } + + /// True if every file in [fileIds] is still on disk exactly as recorded + /// (same size and mtime): nothing local to upload and nothing missing to + /// re-download. Pure local stat calls, no network. + static func localMatchesState( + state: [String: SyncFileState], fileIds: Set, syncRoot: URL + ) -> Bool { + fileIds.allSatisfy { id in + guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else { + return false + } + return local.size == s.size && local.mtimeMs == s.localMTime + } + } + + /// Creates a local directory for every remote folder, so empty folders + /// exist on the device too. + static func mirrorFolders( + entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set = [] + ) { + let fm = FileManager.default + if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) { + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + for entry in entries where entry.isFolder { + let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + // Skip anything unsafe or that would share a local folder with another + // remote folder (a/A on a case-insensitive volume). + guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue } + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + } + + /// Removes local directories under [rootRel] that no longer exist on the + /// server ([remoteFolders], relative paths). Only *empty* directories go: + /// by the time this runs the diff has already deleted the files that were + /// inside a deleted server folder, while a directory that still holds + /// something (a file this device created that hasn't synced) is left alone. + /// Never removes the sync root itself. + static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set) { + let fm = FileManager.default + let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return } + + func prune(_ dir: URL, rel: String) { + let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? [] + for child in children { + let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if childIsDir { + prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent) + } + } + guard !rel.isEmpty, !remoteFolders.contains(rel), + let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty + else { return } + // rmdir, not removeItem: it refuses a directory that isn't empty, so a + // file that appeared since the check survives. + rmdir(dir.path) + } + prune(top, rel: rootRel) + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncModels.swift b/ios/Runner/Native/SyncEngine/SyncModels.swift new file mode 100644 index 0000000..c5becde --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncModels.swift @@ -0,0 +1,95 @@ +import Foundation + +// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times +// are milliseconds since the epoch, like the Kotlin side, so the persisted +// state means the same thing on both platforms. + +/// One file or folder the server reported in a PROPFIND. +struct SyncRemoteEntry: Equatable { + /// Path relative to the user's files root, no trailing slash ("/Docs/a.txt"). + let path: String + let fileId: String + let etag: String + let lastModified: Int64 + let size: Int64 + let isFolder: Bool +} + +/// What the last successful sync recorded for a file - the baseline a later +/// pass diffs the server and the device against. +struct SyncFileState: Codable, Equatable { + let relPath: String + let etag: String + let lastModified: Int64 + let size: Int64 + let localMTime: Int64 +} + +/// A configured sync root's etag at the end of its last clean, complete walk. +/// Nextcloud propagates a change to any descendant up through every ancestor's +/// etag, so an unchanged root etag means nothing beneath it changed. +struct SyncRootMarker: Codable, Equatable { + let etag: String + let fullWalkAt: Int64 +} + +enum SyncAction: Equatable { + case download(SyncRemoteEntry) + case upload(relPath: String, fileId: String) + case delete(relPath: String, fileId: String) + case conflict(SyncRemoteEntry, relPath: String) +} + +/// A file changed both on the device and on the server since the last sync. +struct SyncConflict: Codable, Equatable { + let accountId: String + let fileId: String + let remotePath: String + let relPath: String + let name: String +} + +/// Everything a sync run (foreground or background) needs for one account. +/// Persisted in the Keychain - it carries the auth header. +struct SyncAccountConfig: Codable, Equatable { + let accountId: String + let serverUrl: String + let username: String + let authHeader: String + /// Remote paths (files or folders) to mirror. + var folders: [String] + var wifiOnly: Bool + /// Minutes between background syncs; nil = no background sync (manual and + /// in-app refresh only). + var intervalMinutes: Int? + /// Whether automatic runs may post a summary notification. + var notify: Bool +} + +struct SyncRunSummary: Equatable { + var downloaded = 0 + var uploaded = 0 + var deleted = 0 + var conflicts: [SyncConflict] = [] + + var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 } +} + +/// The server couldn't be reached or answered with an error (anything but a +/// clean 404). Distinct from "the path is genuinely gone": treating a network +/// blip as an empty listing made every previously synced file look deleted +/// server-side, and the diff then deleted the local copies. Callers skip the +/// affected path for this run instead. +struct SyncRemoteUnavailable: Error, CustomStringConvertible { + let message: String + var description: String { message } +} + +/// The recorded sync state exists but couldn't be read (corrupt, or an I/O +/// error). Treating that as "nothing recorded yet" would make every local +/// file look untracked and overwrite local edits with the server's copy, so +/// a run that hits it does nothing at all. +struct SyncStateUnreadable: Error, CustomStringConvertible { + let accountId: String + var description: String { "Sync state for \(accountId) is unreadable" } +} diff --git a/ios/Runner/Native/SyncEngine/SyncRunner.swift b/ios/Runner/Native/SyncEngine/SyncRunner.swift new file mode 100644 index 0000000..45547f2 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncRunner.swift @@ -0,0 +1,387 @@ +import Foundation + +/// A mutex for `async` code: one holder at a time, waiters resume in order. +/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held +/// for the whole of any run that reads-then-rewrites a sync state map, which +/// is also what keeps `SyncStatusBus` - it tracks one account at a time - +/// coherent when several accounts are due. +/// +/// Waiting is cancellable: a task that's cancelled while queued (a background +/// run whose time budget expired, a superseded "Sync now") leaves the queue +/// at once with `CancellationError` instead of sitting behind a long run. +final class AsyncMutex { + private let lock = NSLock() + private var locked = false + private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] + + func withLock(_ body: () async throws -> T) async throws -> T { + try await acquire() + defer { release() } + return try await body() + } + + private func acquire() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + if Task.isCancelled { + lock.unlock() + continuation.resume(throwing: CancellationError()) + } else if !locked { + locked = true + lock.unlock() + continuation.resume() + } else { + waiters.append((id, continuation)) + lock.unlock() + } + } + } onCancel: { + lock.lock() + if let index = waiters.firstIndex(where: { $0.id == id }) { + let waiter = waiters.remove(at: index) + lock.unlock() + waiter.continuation.resume(throwing: CancellationError()) + } else { + lock.unlock() + } + } + } + + private func release() { + lock.lock() + if waiters.isEmpty { + locked = false + lock.unlock() + } else { + let next = waiters.removeFirst() + lock.unlock() + next.continuation.resume() + } + } +} + +/// Live sync status for the app: whether a run is going, which files are +/// transferring, and unresolved conflicts. In-memory only - what actually +/// answers "is this file synced" is the on-disk state; this carries just the +/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that +/// conflicts are kept per account (a file id is only unique within one). +final class SyncStatusBus { + struct Status: Equatable { + var accountId: String? + var syncing = false + var syncingFileIds: Set = [] + /// Every account's unresolved conflicts - readers filter by account. + var conflicts: [SyncConflict] = [] + } + + private let lock = NSLock() + private var current = Status() + + /// Called (on whatever thread changed it) after every update. + var onChange: ((Status) -> Void)? + + func snapshot() -> Status { + lock.lock() + defer { lock.unlock() } + return current + } + + func setSyncing(accountId: String, _ syncing: Bool) { + update { + $0.accountId = accountId + $0.syncing = syncing + if !syncing { $0.syncingFileIds = [] } + } + } + + func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) { + update { + $0.accountId = accountId + if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) } + } + } + + func addConflicts(accountId: String, _ new: [SyncConflict]) { + guard !new.isEmpty else { return } + update { + $0.accountId = accountId + for conflict in new + where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) { + $0.conflicts.append(conflict) + } + } + } + + func removeConflict(accountId: String, fileId: String) { + update { + $0.accountId = accountId + $0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId } + } + } + + private func update(_ mutate: (inout Status) -> Void) { + lock.lock() + mutate(¤t) + let snapshot = current + lock.unlock() + onChange?(snapshot) + } +} + +/// One account's sync pass: walk each configured path, diff it against the +/// recorded state, transfer, and record the result. The iOS port of Android's +/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same +/// safeguards - and a few more, found in review: +/// +/// - If the server can't be reached for a path - or answers with something +/// that isn't a complete, plausible listing - that path is skipped for this +/// run, never diffed against an empty or partial listing, which would look +/// like files were deleted and wipe the local copies. +/// - If the recorded state can't be read the run does nothing at all (it +/// would otherwise treat every local file as untracked and overwrite local +/// edits with the server's copies). +/// - Paths the server sends are checked to stay inside the mirror, and two +/// remote files that would land on the same local file are both skipped. +/// - Server deletions remove the local copy, including local edits, like Android. +/// - A root's etag is only remembered once its whole path synced cleanly, and +/// only if the state it relies on was actually saved. +struct SyncRunner { + let client: DavSyncClient + let store: SyncStore + let bus: SyncStatusBus + var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) } + + /// Even when a root's etag hasn't changed, walk it in full at least this + /// often: etag propagation isn't reliable everywhere (external storage + /// mounts in particular), so this bounds how stale a mirror can get. + static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000 + + /// State is flushed to disk after this many transfers. + static let saveEvery = 20 + + /// [force] is an explicit "Sync now" / newly added path: always a full + /// walk, never trusting the root-etag shortcut. + func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary { + var summary = SyncRunSummary() + guard !config.folders.isEmpty else { return summary } + let accountId = config.accountId + let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + let syncRoot = store.syncRoot(accountId: accountId) + + var state: [String: SyncFileState] + do { + state = try store.loadState(accountId: accountId) + } catch { + NSLog("[Sync] %@ - skipping this run", String(describing: error)) + return summary + } + var markers = store.loadRootMarkers(accountId: accountId) + var sinceSave = 0 + + bus.setSyncing(accountId: accountId, true) + defer { + // State first, then the markers that depend on it, then announce. + if store.saveState(accountId: accountId, state) { + markers = markers.filter { config.folders.contains($0.key) } + store.saveRootMarkers(accountId: accountId, markers) + } else { + NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId) + } + bus.setSyncing(accountId: accountId, false) + } + + func transferred() { + sinceSave += 1 + if sinceSave >= Self.saveEvery { + if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") } + sinceSave = 0 + } + } + + for path in config.folders { + if Task.isCancelled { break } + do { + // A configured path can be a file or a folder - check which before + // deciding whether to walk it or just diff the single item. + let selfEntry = try await client.propfindSelf(creds, path: path) + let priorIds = SyncDiff.priorFileIds(state: state, path: path) + + // Cheap "did anything change?" check: an unchanged root etag means + // nothing beneath it changed on the server, and if every local file + // is also still as recorded there's nothing to upload or re-download + // either - skip the walk (one Depth-0 PROPFIND instead of one per + // subfolder). + if !force, let root = selfEntry, root.isFolder, let marker = markers[path], + marker.etag == root.etag, + now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs, + !priorIds.isEmpty, + SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot) + { + continue + } + + // The server says this synced path no longer exists (a clean 404, not + // a network error): flag it so the app drops it from its synced list. + store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil) + + var entries: [SyncRemoteEntry] = [] + if let root = selfEntry { + if root.isFolder { + entries = try await client.walk(creds, root: path) + } else { + entries = [root] + } + } + var pathClean = true + let collisions = SyncDiff.collidingRelPaths(entries) + + let actions = SyncDiff.diffFolder( + entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot) + for action in actions { + try Task.checkCancellation() + switch action { + case .download(let entry): + let rel = LocalFS.relative(entry.path) + guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else { + NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel) + pathClean = false + continue + } + bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true) + let ok = try await client.download(creds, remotePath: entry.path, to: dest) + bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false) + if ok { + let local = LocalFS.stat(dest) + state[entry.fileId] = SyncFileState( + relPath: rel, etag: entry.etag, lastModified: entry.lastModified, + size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0) + summary.downloaded += 1 + transferred() + } else { + pathClean = false + } + + case .upload(let rel, let fileId): + guard let local = LocalFS.resolve(rel, in: syncRoot) else { + pathClean = false + continue + } + bus.markFileSyncing(accountId: accountId, fileId: fileId, true) + var ok = false + if FileManager.default.fileExists(atPath: local.path) { + ok = try await client.upload(creds, remotePath: "/" + rel, from: local) + } + bus.markFileSyncing(accountId: accountId, fileId: fileId, false) + if ok, let stat = LocalFS.stat(local) { + // Our own PUT changed the file's etag; record the new one, or + // the next pass would see "server changed" and re-download it. + // If it can't be read back, keep the old one and let the root + // be walked for real next time. + let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil + if fresh == nil { pathClean = false } + state[fileId] = SyncFileState( + relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "", + lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs) + summary.uploaded += 1 + transferred() + } else { + pathClean = false + } + + case .delete(let rel, let fileId): + // Only counts as a removal if a local copy existed and went; + // dropping stale state for a file never on disk isn't news. + if let local = LocalFS.resolve(rel, in: syncRoot), + FileManager.default.fileExists(atPath: local.path), + (try? FileManager.default.removeItem(at: local)) != nil + { + summary.deleted += 1 + } + state[fileId] = nil + + case .conflict(let entry, let rel): + summary.conflicts.append( + SyncConflict( + accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel, + name: (rel as NSString).lastPathComponent)) + pathClean = false + } + } + + // Mirror the folder structure itself: empty folders created on the + // server appear on the device, and folders deleted on the server + // (whose files the diff has already removed) don't linger. + if selfEntry == nil || selfEntry?.isFolder == true { + let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if selfEntry != nil { + SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions) + } + var remoteFolders = Set( + entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }) + if selfEntry != nil { remoteFolders.insert(rootRel) } + SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders) + } + + if let root = selfEntry, root.isFolder, pathClean { + markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now()) + } else { + markers[path] = nil + } + } catch is CancellationError { + markers[path] = nil // cut short: make the next run walk it for real + break + } catch { + // Couldn't reach the server for this path (or it answered with + // nothing usable): leave its state and marker alone and try again + // next run. + NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error)) + } + } + + if !summary.conflicts.isEmpty { + bus.addConflicts(accountId: accountId, summary.conflicts) + } + return summary + } + + /// Applies the user's choice for one conflicted file - `local` uploads the + /// device's copy over the server's, `server` downloads the server's over + /// the device's - then refreshes the recorded etag so the next pass doesn't + /// immediately re-flag it. Returns whether it worked. + func resolveConflict( + _ conflict: SyncConflict, resolution: String, creds: SyncCredentials + ) async -> Bool { + // An unreadable baseline must not be overwritten with a one-file one. + guard var state = try? store.loadState(accountId: conflict.accountId), + let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId)) + else { return false } + + var ok = false + do { + switch resolution { + case "local": + if FileManager.default.fileExists(atPath: local.path) { + ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local) + } + case "server": + ok = try await client.download(creds, remotePath: conflict.remotePath, to: local) + default: + break + } + } catch { + return false + } + guard ok else { return false } + + let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil + let stat = LocalFS.stat(local) + state[conflict.fileId] = SyncFileState( + relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0, + size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0) + store.saveState(accountId: conflict.accountId, state) + bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId) + return true + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncStore.swift b/ios/Runner/Native/SyncEngine/SyncStore.swift new file mode 100644 index 0000000..0f5d626 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncStore.swift @@ -0,0 +1,175 @@ +import Foundation + +/// The on-device side of sync: the mirrored files and the per-account sync +/// state. Laid out under one base directory (Application Support, which is +/// also where Dart's `SyncService.baseDirectory()` looks for the mirror): +/// +/// /sync//... the mirrored files +/// /sync-state//*.json state, root markers, missing roots +/// +/// Plain JSON files rather than `UserDefaults`: the state map grows with the +/// number of synced files. Not thread-safe by itself - the sync runner holds +/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`). +final class SyncStore { + /// Bumped whenever a walk starts doing something new (e.g. mirroring empty + /// folders) so markers recorded by an older version - which would make the + /// next run skip the walk - are ignored once. Same value as Android. + static let rootsVersion = 2 + + static let shared = SyncStore(base: defaultBase) + + static var defaultBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + } + + let base: URL + private let fileManager = FileManager.default + + init(base: URL) { + self.base = base + } + + // MARK: - Locations + + /// The account's mirror folder, created on demand. Marked excluded from + /// iCloud/device backups: it's a re-downloadable copy of server data. + func syncRoot(accountId: String) -> URL { + let top = base.appendingPathComponent("sync", isDirectory: true) + let root = top.appendingPathComponent(accountId, isDirectory: true) + try? fileManager.createDirectory(at: root, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var topMutable = top + try? topMutable.setResourceValues(values) + return root + } + + private func stateDirectory(accountId: String) -> URL { + let dir = base.appendingPathComponent("sync-state", isDirectory: true) + .appendingPathComponent(accountId, isDirectory: true) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func file(_ accountId: String, _ name: String) -> URL { + stateDirectory(accountId: accountId).appendingPathComponent(name) + } + + // MARK: - Per-file state + + /// The recorded baseline - empty if nothing has been recorded yet, and + /// *throws* if there is a file that can't be read or decoded (see + /// `SyncStateUnreadable`). + func loadState(accountId: String) throws -> [String: SyncFileState] { + let url = file(accountId, "state.json") + guard fileManager.fileExists(atPath: url.path) else { return [:] } + do { + return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url)) + } catch { + throw SyncStateUnreadable(accountId: accountId) + } + } + + /// False if the state couldn't be written - callers must not then record + /// anything that assumes it was. + @discardableResult + func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool { + write(state, to: file(accountId, "state.json")) + } + + // MARK: - Root markers + + private struct MarkersFile: Codable { + let version: Int + let markers: [String: SyncRootMarker] + } + + func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] { + guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")), + stored.version == Self.rootsVersion + else { return [:] } + return stored.markers + } + + @discardableResult + func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool { + write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json")) + } + + // MARK: - Roots the server says are gone + + func loadMissingRoots(accountId: String) -> Set { + Set(read([String].self, from: file(accountId, "missing.json")) ?? []) + } + + func setRootMissing(accountId: String, path: String, missing: Bool) { + var current = loadMissingRoots(accountId: accountId) + let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil) + if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) } + } + + // MARK: - Removing a path's mirror + + /// Deletes [path]'s local mirror (a file, or a whole folder's worth) and + /// forgets its entries - called when the user turns sync off for that path. + /// Without clearing the state too, a later re-add would see the (now + /// missing) local file as "deleted, server unchanged" and not pull it back. + func removeLocalSync(accountId: String, path: String) { + var cleanPath = path.trimmingCharacters(in: .whitespaces) + if cleanPath.hasPrefix("/") { cleanPath.removeFirst() } + while cleanPath.hasSuffix("/") { cleanPath.removeLast() } + let prefix = cleanPath.isEmpty ? "" : cleanPath + "/" + + // An unreadable baseline is left alone rather than overwritten with a + // partial one; the next run reports it and does nothing. + if var state = try? loadState(accountId: accountId) { + // The sync root itself ("/") covers every recorded file. + for (fileId, entry) in state + where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) { + state[fileId] = nil + } + saveState(accountId: accountId, state) + } + + setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false) + setRootMissing(accountId: accountId, path: cleanPath, missing: false) + + // A root marker for this path (or an ancestor/descendant that also covers + // some of what was just deleted) would make the next background pass + // think nothing changed and skip re-downloading. + let removedPath = "/" + cleanPath + var markers = loadRootMarkers(accountId: accountId) + let stale = markers.keys.filter { key in + var k = key + while k.hasSuffix("/") { k.removeLast() } + return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/") + || removedPath.hasPrefix(k + "/") + } + if !stale.isEmpty { + stale.forEach { markers[$0] = nil } + saveRootMarkers(accountId: accountId, markers) + } + + let root = syncRoot(accountId: accountId) + let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath) + if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) } + if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) } + } + + // MARK: - Helpers + + private func read(_ type: T.Type, from url: URL) -> T? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func write(_ value: T, to url: URL) -> Bool { + guard let data = try? JSONEncoder().encode(value) else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } +} diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift new file mode 100644 index 0000000..a303c07 --- /dev/null +++ b/ios/Runner/Native/TransferManager.swift @@ -0,0 +1,245 @@ +import Foundation +import UserNotifications + +struct UploadFile { + let uri: String + let name: String +} + +/// Uploads and downloads on a background `URLSession`, so a transfer keeps +/// going - and finishes - after the app is suspended or closed. The iOS +/// counterpart of Android's `ShareUploadService`/`DownloadService` +/// foreground services, behind the same Dart channels (`NativeServices`). +/// +/// Uploads are one `PUT` per file straight to the account's WebDAV root +/// (no chunking), from a staged copy in Caches - a background upload task +/// needs a file that stays put, and the picker's temp file may not. Downloads +/// land in `Documents/Downloads`, which is visible in the Files app +/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`). +final class TransferManager: NSObject { + static let shared = TransferManager() + static let sessionIdentifier = "dev.ayushya.noo.transfers" + + /// Called on the main thread when an upload batch ends with at least one + /// file uploaded: (remote folder, succeeded, failed). + var onUploadBatchFinished: ((String, Int, Int) -> Void)? + + /// The system's completion handler from + /// `application(_:handleEventsForBackgroundURLSession:)`, called once the + /// session has delivered every pending event. + var backgroundCompletionHandler: (() -> Void)? + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.waitsForConnectivity = true + // Two accounts can share a server: authenticate by the request's own + // header only, never a cookie another account's request left behind. + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + /// The session the Share Extension's uploads run on (see `ShareUpload`). + /// The extension is gone by the time they finish, so the app recreates it - + /// same identifier and shared container - to receive the results. + private lazy var shareSession = URLSession( + configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil) + + /// Touch both sessions at launch so a system relaunch for finished + /// background transfers reconnects to them. + func reconnect() { + _ = session + _ = shareSession + } + + // MARK: - Starting transfers + + func startUpload( + serverUrl: String, + username: String, + authHeader: String, + files: [UploadFile], + remoteFolder: String + ) throws { + guard !files.isEmpty else { throw TransferError(message: "No files to upload.") } + let batch = UUID().uuidString + let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true) + try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true) + + var tasks: [URLSessionUploadTask] = [] + for (index, file) in files.enumerated() { + guard let source = URL(string: file.uri), source.isFileURL else { + throw TransferError(message: "Can't read \(file.name).") + } + let staged = stageDir.appendingPathComponent("\(index)-\(file.name)") + do { + try FileManager.default.copyItem(at: source, to: staged) + // A file the Share Extension left in the App Group is ours to clean + // up once it's staged; a picker's temp file isn't. + if source.path.contains("/\(SharedInbox.folderName)/") { + try? FileManager.default.removeItem(at: source) + } + } catch { + throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)") + } + guard let url = WebDAV.fileURL( + serverUrl: serverUrl, + username: username, + remotePath: WebDAV.join(remoteFolder, file.name) + ) else { + throw TransferError(message: "Invalid server address.") + } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date { + request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime") + } + let task = session.uploadTask(with: request, fromFile: staged) + task.taskDescription = Self.encode( + TransferTaskInfo( + kind: .upload, batch: batch, name: file.name, + remoteFolder: remoteFolder, stagedPath: staged.path + ) + ) + tasks.append(task) + } + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), + id: batch + ) + TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count) + tasks.forEach { $0.resume() } + } + + func startDownload( + serverUrl: String, + username: String, + authHeader: String, + items: [(path: String, name: String)] + ) throws { + guard !items.isEmpty else { throw TransferError(message: "No files to download.") } + let batch = UUID().uuidString + var tasks: [URLSessionDownloadTask] = [] + for item in items { + guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else { + throw TransferError(message: "Invalid server address.") + } + var request = URLRequest(url: url) + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + let task = session.downloadTask(with: request) + task.taskDescription = Self.encode( + TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil) + ) + tasks.append(task) + } + TransferBatchStore.save( + TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), + id: batch + ) + TransferNotifications.started(kind: .download, batch: batch, count: tasks.count) + tasks.forEach { $0.resume() } + } + + // MARK: - Locations + + private static func stagingDirectory() throws -> URL { + let caches = try FileManager.default.url( + for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return caches.appendingPathComponent("uploads", isDirectory: true) + } + + static func downloadsDirectory() throws -> URL { + let documents = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + let dir = documents.appendingPathComponent("Downloads", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + // MARK: - Batch bookkeeping + + private static func encode(_ info: TransferTaskInfo) -> String? { + (try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) } + } + + private static func info(for task: URLSessionTask) -> TransferTaskInfo? { + task.taskDescription + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) } + } + + /// Counts one file's outcome; fires the batch summary when it was the last. + private func record(_ info: TransferTaskInfo, success: Bool) { + if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) } + } + + private func finish(_ batch: TransferBatch, id: String) { + TransferNotifications.finished(batch, id: id) + reportToDart(batch) + } + + /// Tells Dart (so `FilesController` can refresh the folder) once an upload + /// batch has landed at least one file. + private func reportToDart(_ batch: TransferBatch) { + if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder { + DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) } + } + } + + /// Folders the Share Extension finished uploading into while the app wasn't + /// running - call when the app becomes active. + func deliverExtensionUploads() { + for folder in TransferBatchStore.consumeFinishedUploadFolders() { + DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) } + } + } +} + +// MARK: - URLSession delegates + +extension TransferManager: URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL + ) { + guard let info = Self.info(for: downloadTask) else { return } + let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + record(info, success: false) + return + } + do { + let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name) + try FileManager.default.moveItem(at: location, to: destination) + record(info, success: true) + } catch { + record(info, success: false) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let info = Self.info(for: task) else { return } + switch info.kind { + case .upload: + if let result = UploadResults.handle(task, error: error), let batch = result.batch { + reportToDart(batch) + } + case .download: + // A finished download was already counted in didFinishDownloadingTo; + // only a transport failure arrives here. + if error != nil { record(info, success: false) } + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + self.backgroundCompletionHandler?() + self.backgroundCompletionHandler = nil + } + } +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..3ebfa4a --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.dev.ayushya.noo + + keychain-access-groups + + $(AppIdentifierPrefix)dev.ayushya.noo.shared + + + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b..79e2c65 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -2,11 +2,338 @@ import Flutter import UIKit import XCTest +@testable import Runner + class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testFileURLEncodesEachSegment() { + let url = WebDAV.fileURL( + serverUrl: "https://cloud.example.com", username: "alice", + remotePath: "/Tax Forms/2026 #1/a&b.pdf") + XCTAssertEqual( + url?.absoluteString, + "https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf") } + func testFileURLKeepsServerSubPathAndDropsTrailingSlash() { + let url = WebDAV.fileURL( + serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt") + XCTAssertEqual( + url?.absoluteString, + "https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt") + } + + func testFileURLForRoot() { + XCTAssertEqual( + WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString, + "https://c.io/remote.php/dav/files/u") + } + + func testJoinUsesOneSlash() { + XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt") + XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt") + XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt") + } + + func testUniqueURLNumbersCollisions() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)") + } + + // MARK: - SharedInbox (Share Extension hand-off) + + private func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return root + } + + private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem { + let batch = try SharedInbox.makeBatchDirectory(in: root) + let file = batch.appendingPathComponent(name) + try contents.write(to: file, atomically: true, encoding: .utf8) + return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count)) + } + + func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws { + let root = try makeRoot() + let a = try makeSharedFile(in: root, name: "a.txt") + let b = try makeSharedFile(in: root, name: "b.txt") + try SharedInbox.append([a], in: root) + try SharedInbox.append([b], in: root) + + XCTAssertEqual(SharedInbox.consume(in: root), [a, b]) + XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once") + } + + func testInboxConsumeDropsFilesThatNoLongerExist() throws { + let root = try makeRoot() + let kept = try makeSharedFile(in: root, name: "kept.txt") + let gone = try makeSharedFile(in: root, name: "gone.txt") + try SharedInbox.append([kept, gone], in: root) + try FileManager.default.removeItem(atPath: gone.path) + + XCTAssertEqual(SharedInbox.consume(in: root), [kept]) + } + + func testInboxConsumeWithNothingPendingIsEmpty() throws { + XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), []) + } + + func testInboxAppendOfNothingCreatesNoManifest() throws { + let root = try makeRoot() + try SharedInbox.append([], in: root) + XCTAssertEqual(SharedInbox.consume(in: root), []) + } + + // MARK: - DavFolderParser (Share Extension folder picker) + + private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data { + let body = responses.map { r in + """ + \(r.href)\ + \(r.collection ? "" : "")\ + HTTP/1.1 200 OK + """ + }.joined() + return Data( + "\(body)".utf8) + } + + func testFoldersListsOnlyChildFoldersSortedWithoutHidden() { + let xml = multistatus([ + ("/remote.php/dav/files/alice/Docs/", true), + ("/remote.php/dav/files/alice/Docs/zeta/", true), + ("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true), + ("/remote.php/dav/files/alice/Docs/note.txt", false), + ("/remote.php/dav/files/alice/Docs/.git/", true), + ]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs"), + [DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name), + [".git", "Tax Forms", "zeta"]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name), + [".git"]) + } + + func testHiddenFilterTreatsAnyDotSegmentAsHidden() { + XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache")) + XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden") + XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms")) + XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs")) + XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git")) + XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks")) + XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs")) + XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs")) + XCTAssertEqual(HiddenFilter(raw: "include"), .include) + XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding") + } + + func testFoldersAtRootAndUnderServerSubPath() { + let xml = multistatus([ + ("/nextcloud/remote.php/dav/files/bob/", true), + ("/nextcloud/remote.php/dav/files/bob/Photos/", true), + ]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/"), + [DavFolder(name: "Photos", path: "/Photos")]) + } + + func testRelativePath() { + XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D") + XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/") + XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else")) + } + + func testFoldersIgnoresGarbage() { + XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), []) + } + + // MARK: - Shared account (Keychain group shared with the extension) + + private func account(_ id: String, hidden: String = "hide") -> SharedAccount { + SharedAccount( + id: id, serverUrl: "https://\(id).example.com", username: id, + authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden) + } + + func testSharedAccountsRoundTripThroughTheKeychain() throws { + let accounts = SharedAccounts( + accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob", + loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false) + addTeardownBlock { SharedAccountStore.clear() } + + SharedAccountStore.clear() + XCTAssertNil(SharedAccountStore.load()) + try SharedAccountStore.save(accounts) + XCTAssertEqual(SharedAccountStore.load(), accounts) + + let replacement = SharedAccounts( + accounts: [account("carol")], activeId: "carol", + loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false) + try SharedAccountStore.save(replacement) + XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates") + + SharedAccountStore.clear() + XCTAssertNil(SharedAccountStore.load()) + } + + func testSharedAccountsActiveFallsBackToTheFirstAccount() { + var shared = SharedAccounts( + accounts: [account("alice"), account("bob")], activeId: "bob", + loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false) + XCTAssertEqual(shared.active?.id, "bob") + shared.activeId = "gone" + XCTAssertEqual(shared.active?.id, "alice") + shared.activeId = nil + XCTAssertEqual(shared.active?.id, "alice") + } + + func testUnlockRulesAreIndependentOfLoginLock() { + func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) { + let shared = SharedAccounts( + accounts: [account("a")], activeId: "a", + loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden) + return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden) + } + XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false)) + XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true)) + XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false)) + XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true)) + } + + // Which actions in the share sheet ask to unlock - and how many prompts. + + private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts { + SharedAccounts( + accounts: [account("alice"), account("bob")], activeId: "alice", + loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden) + } + + func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() { + let shared = lockedAccounts(switching: true, hidden: true) + let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false)) + XCTAssertFalse(needs.needsPrompt) + } + + func testChoosingAnotherAccountPromptsEveryTime() { + let shared = lockedAccounts(switching: true, hidden: false) + // No unlock is remembered: the same choice asks again on every call. + for _ in 0..<3 { + XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt) + } + XCTAssertFalse( + lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt) + } + + func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() { + let needs = lockedAccounts(switching: true, hidden: true) + .unlockNeeds(choosing: account("bob"), showing: .include) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true)) + XCTAssertTrue(needs.needsPrompt) + } + + func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() { + let needs = lockedAccounts(switching: true, hidden: true) + .unlockNeeds(choosing: account("alice"), showing: .only) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true)) + } + + func testOnlyTurningHiddenFoldersOnAsksToUnlock() { + let locked = lockedAccounts(switching: false, hidden: true) + XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only)) + XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include)) + XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks") + XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks") + XCTAssertFalse( + lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include), + "no lock set, no prompt") + } + + // MARK: - TransferBatchStore + + func testBatchSummaryFiresOnlyOnTheLastFile() { + let batchId = UUID().uuidString + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId) + let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil) + + XCTAssertNil(TransferBatchStore.record(info, success: true)) + XCTAssertNil(TransferBatchStore.record(info, success: false)) + let finished = TransferBatchStore.record(info, success: true) + XCTAssertEqual(finished?.succeeded, 2) + XCTAssertEqual(finished?.failed, 1) + XCTAssertEqual(finished?.remoteFolder, "/Docs") + } + + func testFinishedUploadFoldersAreNotedOnceAndConsumed() { + _ = TransferBatchStore.consumeFinishedUploadFolders() // start clean + TransferBatchStore.noteFinishedUpload(folder: "/Docs") + TransferBatchStore.noteFinishedUpload(folder: "/Docs") + TransferBatchStore.noteFinishedUpload(folder: "/Photos") + + XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"]) + XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), []) + } + + func testNotificationFileCountWording() { + XCTAssertEqual(TransferNotifications.files(1), "1 file") + XCTAssertEqual(TransferNotifications.files(3), "3 files") + } + + // MARK: - External storage + tolerant decoding + + func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() { + let xml = Data( + """ + + /remote.php/dav/files/a/ + + /remote.php/dav/files/a/NAS/ + external + + /remote.php/dav/files/a/Docs/ + + /remote.php/dav/files/a/Shared/ + shared + + + """.utf8) + let folders = DavFolderParser.folders(from: xml, excluding: "/") + XCTAssertEqual(folders.map(\.name), ["Docs", "NAS", "Shared"]) + XCTAssertEqual(folders.map(\.isExternal), [false, true, false], "only mount-type=external counts") + + XCTAssertTrue(StorageFilter.cloud.shows(isExternal: false)) + XCTAssertFalse(StorageFilter.cloud.shows(isExternal: true)) + XCTAssertTrue(StorageFilter.external.shows(isExternal: true)) + XCTAssertFalse(StorageFilter.external.shows(isExternal: false)) + XCTAssertTrue(StorageFilter.all.shows(isExternal: true) && StorageFilter.all.shows(isExternal: false)) + XCTAssertEqual(StorageFilter(raw: "bogus"), .cloud) + } + + func testSharedAccountDecodesDataWrittenBeforeNewFieldsExisted() throws { + let old = Data( + """ + {"id":"a","serverUrl":"https://x","username":"u","authHeader":"Basic x","displayName":"u@x"} + """.utf8) + let decoded = try JSONDecoder().decode(SharedAccount.self, from: old) + XCTAssertEqual(decoded.hiddenFilter, "hide") + XCTAssertEqual(decoded.storageScope, "cloud") + } } diff --git a/ios/RunnerTests/SyncEngineTests.swift b/ios/RunnerTests/SyncEngineTests.swift new file mode 100644 index 0000000..f1804dc --- /dev/null +++ b/ios/RunnerTests/SyncEngineTests.swift @@ -0,0 +1,875 @@ +import XCTest + +@testable import Runner + +/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1, +/// GET, PUT) for the sync engine to run end to end against - with ancestor +/// etags bumping on every change, like the real thing. +final class FakeDav: URLProtocol { + struct Node { + var isFolder: Bool + var data = Data() + var etag: String + var fileId: String + var mtime = Date(timeIntervalSince1970: 1_700_000_000) + } + + static var nodes: [String: Node] = [:] + static var failAll = false + /// A folder whose PROPFIND answers 500 while everything else works - for + /// proving a half-built manifest is never trusted. + static var failPropfindPath: String? + /// Answer PROPFIND with a 207 whose XML is cut off mid-way. + static var truncatePropfind = false + static var log: [String] = [] + private static var counter = 0 + static let user = "alice" + private static let base = "/remote.php/dav/files/alice" + + static func reset() { + nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")] + failAll = false + failPropfindPath = nil + truncatePropfind = false + log = [] + counter = 0 + } + + private static func nextTag() -> String { + counter += 1 + return "tag\(counter)" + } + + private static func parent(of path: String) -> String { + let p = (path as NSString).deletingLastPathComponent + return p.isEmpty ? "/" : p + } + + /// Bumps [path]'s etag and every ancestor's. + private static func touch(_ path: String) { + var p = path + while true { + nodes[p]?.etag = nextTag() + if p == "/" { break } + p = parent(of: p) + } + } + + // MARK: Test-side helpers + + static func mkdir(_ path: String) { + var parts: [String] = [] + for comp in path.split(separator: "/") { + parts.append(String(comp)) + let p = "/" + parts.joined(separator: "/") + if nodes[p] == nil { + nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)") + touch(parent(of: p)) + } + } + } + + static func put(_ path: String, _ text: String) { + mkdir(parent(of: path)) + var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())") + node.data = Data(text.utf8) + nodes[path] = node + touch(path) + } + + static func remove(_ path: String) { + for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil } + touch(parent(of: path)) + } + + static func text(_ path: String) -> String? { + nodes[path].flatMap { String(data: $0.data, encoding: .utf8) } + } + + // MARK: URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + let request = self.request + let method = request.httpMethod ?? "GET" + let depth = request.value(forHTTPHeaderField: "Depth") ?? "-" + var path = (request.url?.path ?? "").removingPercentEncoding ?? "" + if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) } + while path.count > 1 && path.hasSuffix("/") { path.removeLast() } + if path.isEmpty { path = "/" } + Self.log.append("\(method) \(depth) \(path)") + + func send(_ status: Int, _ body: Data = Data()) { + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + if Self.failAll { return send(503) } + + switch method { + case "PROPFIND": + if path == Self.failPropfindPath { return send(500) } + guard Self.nodes[path] != nil else { return send(404) } + var paths = [path] + if depth == "1" { + paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted() + } + var body = Self.multistatus(paths) + // Only folder listings: a cut-off *listing* still holds a few complete + // responses, which is the dangerous case (a partial manifest). + if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) } + send(207, Data(body.utf8)) + case "GET": + guard let node = Self.nodes[path], !node.isFolder else { return send(404) } + send(200, node.data) + case "PUT": + var data = Data() + if let stream = request.httpBodyStream { + stream.open() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + if n <= 0 { break } + data.append(buffer, count: n) + } + stream.close() + } else if let body = request.httpBody { + data = body + } + Self.mkdir(Self.parent(of: path)) + var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())") + node.data = data + Self.nodes[path] = node + Self.touch(path) + send(201) + default: + send(405) + } + } + + private static let httpDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + return f + }() + + private static func multistatus(_ paths: [String]) -> String { + let responses = paths.map { p -> String in + let node = nodes[p]! + let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/") + let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "") + return """ + \(href)\ + \(httpDate.string(from: node.mtime))\ + \(node.data.count)\ + \(node.isFolder ? "" : "")\ + "\(node.etag)"\(node.fileId)\ + HTTP/1.1 200 OK + """ + }.joined() + return """ + \(responses) + """ + } +} + +final class SyncEngineTests: XCTestCase { + private var store: SyncStore! + private var bus: SyncStatusBus! + private var runner: SyncRunner! + private var fakeSession: URLSession! + private var clock: Int64 = 1_700_000_000_000 + private let config = SyncAccountConfig( + accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x", + folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false) + private var creds: SyncCredentials { + SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + } + + override func setUp() { + super.setUp() + FakeDav.reset() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: base) } + store = SyncStore(base: base) + bus = SyncStatusBus() + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [FakeDav.self] + fakeSession = URLSession(configuration: sessionConfig) + runner = SyncRunner( + client: DavSyncClient(session: fakeSession), + store: store, bus: bus, now: { [unowned self] in self.clock }) + } + + private func state() -> [String: SyncFileState] { + (try? store.loadState(accountId: "acct")) ?? [:] + } + + private var mirror: URL { store.syncRoot(accountId: "acct") } + private func local(_ rel: String) -> String? { + try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8) + } + private func exists(_ rel: String) -> Bool { + FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path) + } + private func editLocally(_ rel: String, _ text: String) throws { + let url = mirror.appendingPathComponent(rel) + try text.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path) + } + private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count } + + private func seedServer() { + FakeDav.put("/Docs/a.txt", "alpha") + FakeDav.put("/Docs/sub/b.txt", "bravo") + } + + // MARK: - The happy path + + func testFirstSyncDownloadsEverythingAndMirrorsFolders() async { + seedServer() + FakeDav.mkdir("/Docs/empty") + let summary = await runner.run(config, force: false) + + XCTAssertEqual(summary.downloaded, 2) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored") + XCTAssertEqual(state().count, 2) + XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"]) + XCTAssertFalse(bus.snapshot().syncing, "announced as finished") + } + + func testUnchangedServerIsOneCheapRequestNotAWalk() async { + seedServer() + _ = await runner.run(config, force: false) + let walks = depth1Requests() + + FakeDav.log = [] + let summary = await runner.run(config, force: false) + XCTAssertFalse(summary.changedAnything) + XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)") + XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"]) + } + + func testForceAlwaysWalks() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.log = [] + _ = await runner.run(config, force: true) + XCTAssertGreaterThan(depth1Requests(), 0) + } + + func testShortcutExpiresAfterTheMaxAge() async { + seedServer() + _ = await runner.run(config, force: false) + clock += SyncRunner.fullWalkMaxAgeMs + 1 + FakeDav.log = [] + _ = await runner.run(config, force: false) + XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever") + } + + func testServerChangeIsDownloaded() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "alpha v2") + FakeDav.put("/Docs/new.txt", "fresh") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 2) + XCTAssertEqual(local("Docs/a.txt"), "alpha v2") + XCTAssertEqual(local("Docs/new.txt"), "fresh") + } + + func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "alpha edited on phone") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.uploaded, 1) + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone") + + let again = await runner.run(config, force: false) + XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change") + XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone") + } + + func testFileDeletedLocallyIsPulledBack() async throws { + seedServer() + _ = await runner.run(config, force: false) + try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt")) + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server") + } + + // MARK: - Conflicts + + func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"]) + XCTAssertEqual(summary.conflicts.first?.name, "a.txt") + XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched") + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched") + XCTAssertEqual(bus.snapshot().conflicts.count, 1) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time") + } + + func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds) + XCTAssertTrue(ok) + XCTAssertEqual(local("Docs/a.txt"), "server edit") + XCTAssertTrue(bus.snapshot().conflicts.isEmpty) + + let next = await runner.run(config, force: false) + XCTAssertTrue(next.conflicts.isEmpty) + XCTAssertFalse(next.changedAnything) + } + + func testResolveWithLocalCopyUploadsIt() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds) + XCTAssertTrue(ok) + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit") + let next = await runner.run(config, force: false) + XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything) + } + + func testAnUnknownResolutionDoesNothing() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds) + XCTAssertFalse(ok) + XCTAssertEqual(local("Docs/a.txt"), "phone edit") + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit") + } + + // MARK: - Deletes (the dangerous part) + + func testFileDeletedOnTheServerIsRemovedLocally() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs/a.txt") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/a.txt")) + XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes") + } + + func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs/sub") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/sub/b.txt")) + XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned") + XCTAssertTrue(exists("Docs/a.txt")) + } + + func testAnUnreachableServerNeverDeletesAnything() async { + seedServer() + _ = await runner.run(config, force: false) + let before = state() + + FakeDav.failAll = true + let summary = await runner.run(config, force: true) + XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertEqual(state(), before) + XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'") + + FakeDav.failAll = false + let recovered = await runner.run(config, force: true) + XCTAssertFalse(recovered.changedAnything) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + } + + func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async { + seedServer() + _ = await runner.run(config, force: false) + // Only the subfolder listing breaks: a half-built manifest must not make + // sub/b.txt look deleted. + FakeDav.nodes["/Docs/sub"]?.etag = "changed" + FakeDav.nodes["/Docs"]?.etag = "changed" + FakeDav.failPropfindPath = "/Docs/sub" + + let summary = await runner.run(config, force: true) + XCTAssertEqual(summary.deleted, 0) + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + } + + func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 2) + XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"]) + XCTAssertFalse(exists("Docs/a.txt")) + } + + // MARK: - Single files, scoping, housekeeping + + func testASyncedSingleFilePath() async { + FakeDav.put("/Notes/todo.txt", "buy milk") + var single = config + single.folders = ["/Notes/todo.txt"] + + let summary = await runner.run(single, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Notes/todo.txt"), "buy milk") + FakeDav.put("/Notes/todo.txt", "buy milk and eggs") + _ = await runner.run(single, force: false) + XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs") + } + + func testSyncOnlyTouchesTheConfiguredPaths() async { + seedServer() + FakeDav.put("/Other/x.txt", "not synced") + _ = await runner.run(config, force: false) + XCTAssertFalse(exists("Other/x.txt")) + } + + func testNothingConfiguredDoesNothing() async { + seedServer() + var empty = config + empty.folders = [] + let summary = await runner.run(empty, force: true) + XCTAssertEqual(summary, SyncRunSummary()) + XCTAssertTrue(FakeDav.log.isEmpty) + } + + func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async { + seedServer() + _ = await runner.run(config, force: false) + store.removeLocalSync(accountId: "acct", path: "/Docs/sub") + + XCTAssertFalse(exists("Docs/sub/b.txt")) + XCTAssertTrue(exists("Docs/a.txt")) + XCTAssertEqual(state().count, 1) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + } + + func testStatusMarksFilesAsSyncingDuringTheRun() async { + seedServer() + var seen = Set() + bus.onChange = { seen.formUnion($0.syncingFileIds) } + _ = await runner.run(config, force: false) + XCTAssertEqual(seen.count, 2, "each transferring file was announced") + XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty) + } + + func testStaleMarkersForRemovedPathsAreDropped() async { + seedServer() + _ = await runner.run(config, force: false) + var other = config + other.folders = ["/Other"] + FakeDav.mkdir("/Other") + _ = await runner.run(other, force: false) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"]) + } + + // MARK: - Parsing + + func testParserReadsEtagFileIdSizeAndDates() { + let xml = Data( + """ + + /nextcloud/remote.php/dav/files/alice/Docs/ + "abc123" + 7 + /nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf + Tue, 14 Nov 2023 22:13:20 GMT + 2048 + "e1"9 + + """.utf8) + let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true) + XCTAssertEqual(entries.count, 1, "the folder itself is skipped") + XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf") + XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped") + XCTAssertEqual(entries[0].fileId, "9") + XCTAssertEqual(entries[0].size, 2048) + XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000) + XCTAssertFalse(entries[0].isFolder) + + let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false) + XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"]) + XCTAssertTrue(all[0].isFolder) + } + + func testParserRefusesAnythingThatIsNotACompleteAnswer() { + func entries(_ xml: String) throws -> [SyncRemoteEntry] { + try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false) + } + XCTAssertThrowsError(try entries("nope"), "not XML") + XCTAssertThrowsError(try entries("/remote.php"), "truncated") + XCTAssertThrowsError( + try entries( + "/remote.php/dav/files/alice/Docs/a.txt" + + "/remote.php/dav/files/alice/Docs/b"), + "one complete response, then cut off: a partial manifest") + XCTAssertThrowsError(try entries("Maintenance"), "well-formed but not a multistatus") + XCTAssertThrowsError(try entries(""), "a 207 always has the resource itself") + } + + func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() { + func parse(_ href: String) throws -> [SyncRemoteEntry] { + let xml = "\(href)" + return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false) + } + XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt")) + XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component") + XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot") + XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root") + XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same") + } + + // MARK: - Review findings: each of these used to lose or misplace data + + func testATruncatedAnswerIsNotAManifest() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.truncatePropfind = true + + let summary = await runner.run(config, force: true) + XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone") + } + + func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "my only copy of this edit") + FakeDav.remove("/Docs/a.txt") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/a.txt")) + XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked") + + let again = await runner.run(config, force: false) + XCTAssertFalse(exists("Docs/a.txt"), "not restored on a later sync") + XCTAssertFalse(again.changedAnything) + } + + func testSyncingTheWholeAccountNoticesServerDeletions() async { + FakeDav.put("/a.txt", "top") + FakeDav.put("/sub/b.txt", "nested") + var everything = config + everything.folders = ["/"] + + _ = await runner.run(everything, force: false) + XCTAssertEqual(local("a.txt"), "top") + FakeDav.log = [] + _ = await runner.run(everything, force: false) + XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too") + + FakeDav.remove("/a.txt") + let summary = await runner.run(everything, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("a.txt")) + XCTAssertTrue(exists("sub/b.txt")) + } + + func testAPathNeverClaimsASiblingWithTheSamePrefix() { + let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) } + let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")] + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"]) + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"]) + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything") + } + + func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async { + FakeDav.put("/Docs/a.txt", "lower") + FakeDav.put("/Docs/A.txt", "upper") + FakeDav.put("/Docs/fine.txt", "ok") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1, "only the file with no collision") + XCTAssertEqual(local("Docs/fine.txt"), "ok") + XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other") + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean") + let unicode = SyncDiff.collidingRelPaths([ + SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false), + SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false), + ]) + XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"), + "canonically equivalent Unicode spellings collide too") + XCTAssertTrue( + SyncDiff.collidingRelPaths([ + SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false) + ]).isEmpty, "one spelling is never a collision") + } + + func testServerPathsCanNeverLeaveTheMirror() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)") + XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root)) + XCTAssertNil(LocalFS.resolve("../outside.txt", in: root)) + XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root)) + XCTAssertNil(LocalFS.resolve("./x", in: root)) + XCTAssertNil(LocalFS.resolve("", in: root)) + XCTAssertNil(LocalFS.resolve("/", in: root)) + } + + func testAnUnreadableStateMakesTheRunDoNothing() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "unsynced local edit") + let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json") + try Data("{ this is not json".utf8).write(to: stateFile) + FakeDav.log = [] + + let summary = await runner.run(config, force: true) + XCTAssertEqual(summary, SyncRunSummary()) + XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten") + XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server") + XCTAssertThrowsError(try store.loadState(accountId: "acct")) + XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten") + } + + func testNoStateYetIsJustAFirstSyncNotAnError() throws { + XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:]) + } + + func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws { + FakeDav.put("/Docs/a.txt", "server") + let client = DavSyncClient(session: fakeSession) + let folder = mirror.appendingPathComponent("Docs/a.txt") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8) + + let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder) + XCTAssertFalse(ok, "a folder is not replaced by a file") + XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside") + + let file = mirror.appendingPathComponent("Docs/b.txt") + try "old".write(to: file, atomically: true, encoding: .utf8) + FakeDav.put("/Docs/b.txt", "new") + let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file) + XCTAssertTrue(replaced) + XCTAssertEqual(local("Docs/b.txt"), "new") + FakeDav.failAll = true + let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file) + XCTAssertFalse(failed) + XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there") + } + + func testRemovingTheWholeRootForgetsEveryFile() async { + seedServer() + _ = await runner.run(config, force: false) + store.removeLocalSync(accountId: "acct", path: "/") + XCTAssertTrue(state().isEmpty, "'/' covers everything recorded") + XCTAssertFalse(exists("Docs/a.txt")) + } + + func testPruningNeverRemovesAFolderThatHoldsSomething() throws { + let root = mirror + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true) + try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true) + + SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"]) + XCTAssertEqual(local("Docs/stale/note.txt"), "mine") + XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes") + } + + // MARK: - Status, coordinator, config store, mutex + + func testConflictsAreKeptPerAccount() { + let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x") + let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y") + bus.addConflicts(accountId: "A", [a]) + bus.addConflicts(accountId: "B", [b]) + XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept") + + let coordinator = SyncCoordinator( + store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient()) + let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]] + XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts") + + bus.removeConflict(accountId: "A", fileId: "7") + XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's") + } + + func testBackgroundSlotOnlyRunsAccountsWithPeriodicSyncEnabled() async { + seedServer() + let configs = SyncConfigStore(service: "test-\(UUID().uuidString)") + addTeardownBlock { configs.removeAll() } + let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient(session: fakeSession)) + var scheduled = config + scheduled.intervalMinutes = nil + scheduled.wifiOnly = false + configs.upsert(scheduled) + + let skipped = expectation(description: "manual-only account skipped") + coordinator.startBackgroundSlot { success in + XCTAssertTrue(success) + skipped.fulfill() + } + await fulfillment(of: [skipped], timeout: 10) + XCTAssertFalse(exists("Docs/a.txt")) + + scheduled.intervalMinutes = 30 + configs.upsert(scheduled) + let completed = expectation(description: "periodic account synced") + coordinator.startBackgroundSlot { success in + XCTAssertTrue(success) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 15) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + } + + func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() { + let service = "test-\(UUID().uuidString)" + let configs = SyncConfigStore(service: service) + addTeardownBlock { configs.removeAll() } + let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient()) + var withBackground = config + withBackground.intervalMinutes = 30 + + coordinator.reschedule(withBackground) + XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30) + + coordinator.cancel(accountId: "acct", forget: false) + XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials") + XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled") + + coordinator.cancel(accountId: "acct", forget: true) + XCTAssertNil(configs.config(for: "acct"), "signing out forgets them") + } + + func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() { + let configs = SyncConfigStore(service: "test-\(UUID().uuidString)") + addTeardownBlock { configs.removeAll() } + let second = SyncAccountConfig( + accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y", + folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true) + + XCTAssertTrue(configs.upsert(config)) + XCTAssertTrue(configs.upsert(second)) + XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"]) + + var changed = config + changed.folders = ["/Docs", "/More"] + XCTAssertTrue(configs.upsert(changed)) + XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"]) + XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others") + + XCTAssertTrue(configs.remove(accountId: "acct")) + XCTAssertEqual(configs.all().map(\.accountId), ["other"]) + XCTAssertTrue(configs.remove(accountId: "never-existed")) + } + + func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws { + let mutex = AsyncMutex() + let holderStarted = expectation(description: "holder has the lock") + let holder = Task { + try await mutex.withLock { + holderStarted.fulfill() + try await Task.sleep(nanoseconds: 1_500_000_000) + } + } + await fulfillment(of: [holderStarted], timeout: 5) + + let waiter = Task { try await mutex.withLock { "ran" } } + try await Task.sleep(nanoseconds: 100_000_000) + let cancelledAt = Date() + waiter.cancel() + do { + _ = try await waiter.value + XCTFail("a cancelled waiter must not run") + } catch { + XCTAssertTrue(error is CancellationError) + XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder") + } + + try await holder.value + let next = try await mutex.withLock { "next" } + XCTAssertEqual(next, "next", "the lock is free for the next one") + } + + private final class Counter: @unchecked Sendable { + var running = 0 + var maxRunning = 0 + var finished = 0 + } + + func testAMutexNeverRunsTwoBodiesAtOnce() async throws { + let mutex = AsyncMutex() + let counter = Counter() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<6 { + group.addTask { + _ = try? await mutex.withLock { + counter.running += 1 + counter.maxRunning = max(counter.maxRunning, counter.running) + try? await Task.sleep(nanoseconds: 10_000_000) + counter.running -= 1 + counter.finished += 1 + } + } + } + } + XCTAssertEqual(counter.finished, 6) + XCTAssertEqual(counter.maxRunning, 1) + } + + func testOnceGateCompletesOnlyOnce() { + var calls: [Bool] = [] + let gate = OnceGate { calls.append($0) } + gate.finish(false) + gate.finish(true) + XCTAssertEqual(calls, [false]) + } +} diff --git a/ios/ShareExtension/DeviceAuth.swift b/ios/ShareExtension/DeviceAuth.swift new file mode 100644 index 0000000..b7c4cab --- /dev/null +++ b/ios/ShareExtension/DeviceAuth.swift @@ -0,0 +1,19 @@ +import LocalAuthentication + +/// Face ID / Touch ID with the device passcode as the fallback - the same +/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so +/// the share sheet asks for exactly what the app would. +enum DeviceAuth { + /// True only if the user actually authenticated; a cancel, a failure, or a + /// device with nothing to authenticate with all count as "not unlocked". + static func authenticate(reason: String) async -> Bool { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false } + return await withCheckedContinuation { continuation in + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in + continuation.resume(returning: success) + } + } + } +} diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist new file mode 100644 index 0000000..68865d3 --- /dev/null +++ b/ios/ShareExtension/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Noo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSFaceIDUsageDescription + Noo uses Face ID to unlock hidden folders and accounts when you share a file. + NooKeychainAccessGroup + $(AppIdentifierPrefix)dev.ayushya.noo.shared + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsFileWithMaxCount + 50 + NSExtensionActivationSupportsImageWithMaxCount + 50 + NSExtensionActivationSupportsMovieWithMaxCount + 50 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ios/ShareExtension/ShareExtension.entitlements b/ios/ShareExtension/ShareExtension.entitlements new file mode 100644 index 0000000..3ebfa4a --- /dev/null +++ b/ios/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.dev.ayushya.noo + + keychain-access-groups + + $(AppIdentifierPrefix)dev.ayushya.noo.shared + + + diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift new file mode 100644 index 0000000..ff9cd1a --- /dev/null +++ b/ios/ShareExtension/SharePickerView.swift @@ -0,0 +1,406 @@ +import SwiftUI + +/// State behind the share sheet's account and folder pickers. +@MainActor +final class ShareModel: ObservableObject { + enum Stage: Equatable { + case preparing + case chooseAccount + case picking + case uploading + case noAccount + case failed(String) + } + + @Published var stage: Stage = .preparing + @Published var items: [SharedItem] = [] + @Published var path = "/" + @Published var isLoadingFolders = false + @Published var folderError: String? + /// A short explanation shown under the list - e.g. hidden folders stayed + /// hidden because the unlock was cancelled. + @Published var notice: String? + @Published private(set) var selected: SharedAccount? + + /// The sheet's two toggles. They start at the app's own setting for the + /// chosen account and can be changed for this share only. + @Published private(set) var hiddenFilter: HiddenFilter = .hide + @Published private(set) var storageFilter: StorageFilter = .cloud + + /// Every child folder of [path], unfiltered; the toggles filter it live. + @Published private var allFolders: [DavFolder] = [] + /// Mount points of external storage seen so far. Only the mount's root is + /// flagged by the server, so anything under one of these is external too. + private var externalRoots: Set = [] + + let shared: SharedAccounts? + + /// Set by the view controller: what each button actually does. + var onUpload: () -> Void = {} + var onSaveForLater: () -> Void = {} + var onCancel: () -> Void = {} + + init(shared: SharedAccounts?) { + self.shared = shared + } + + var accounts: [SharedAccount] { shared?.accounts ?? [] } + var canChangeAccount: Bool { accounts.count > 1 } + var activeAccountId: String? { shared?.active?.id } + + var currentFolderName: String { + path == "/" ? "All files" : (path as NSString).lastPathComponent + } + + var fileCountText: String { + items.count == 1 ? "1 file" : "\(items.count) files" + } + + // MARK: - Folders, filtered by the toggles + + private func isExternal(_ folder: DavFolder) -> Bool { + folder.isExternal + || externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") } + } + + private var visibleFolders: [DavFolder] { + allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) } + } + + /// Internal folders - and, for "All", the external ones are listed apart. + var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } } + var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } } + + // MARK: - Accounts + + /// With several accounts the list comes first; with one it goes straight + /// to its folders. + func start() async { + guard let shared, let first = shared.accounts.first else { + stage = .noAccount + return + } + NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none") + if shared.accounts.count > 1 { + stage = .chooseAccount + } else { + await select(first) + } + } + + func showAccounts() { + notice = nil + stage = .chooseAccount + } + + /// Uploading to an account other than the one the app is on is "switching" + /// in the app's terms, so it asks for the same unlock when the app does. + /// The toggles start at that account's own app settings; hidden folders + /// are behind the app's "lock hidden files" unlock. + func select(_ account: SharedAccount) async { + guard let shared else { return } + notice = nil + var wantedHidden = HiddenFilter(raw: account.hiddenFilter) + + // Every selection asks again, like the app does for every switch - no + // unlock is remembered. If one action needs both unlocks, one prompt + // covers both. + let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden) + if needs.needsPrompt { + let reason = needs.switchesAccount + ? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders" + if await !DeviceAuth.authenticate(reason: reason) { + guard !needs.switchesAccount else { + notice = "Unlock to upload to a different account." + stage = .chooseAccount + return + } + wantedHidden = .hide + notice = "Hidden folders are locked, so they're not shown." + } + } + NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope) + selected = account + externalRoots = [] + storageFilter = StorageFilter(raw: account.storageScope) + hiddenFilter = wantedHidden + stage = .picking + await open("/") + } + + // MARK: - Toggles + + /// Turning hidden folders *on* is what the app locks; going back to + /// hiding them never exposes anything, and neither does switching + /// between the two revealing modes. + func setHiddenFilter(_ filter: HiddenFilter) async { + guard filter != hiddenFilter else { return } + if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true { + guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else { + notice = "Hidden folders are locked, so they're not shown." + return + } + } + notice = nil + hiddenFilter = filter + await open("/") + } + + func setStorageFilter(_ filter: StorageFilter) async { + guard filter != storageFilter else { return } + notice = nil + storageFilter = filter + await open("/") + } + + // MARK: - Browsing + + /// A toggle can leave you inside a folder it now hides, so changing one + /// always returns to the top - same as [open] with "/". + func open(_ path: String) async { + guard let account = selected else { return } + self.path = path + isLoadingFolders = true + folderError = nil + defer { isLoadingFolders = false } + do { + allFolders = try await DavClient.listFolders(account: account, path: path) + externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path)) + } catch { + allFolders = [] + folderError = error.localizedDescription + } + } + + func openParent() async { + let parent = (path as NSString).deletingLastPathComponent + await open(parent.isEmpty ? "/" : parent) + } +} + +struct SharePickerView: View { + @ObservedObject var model: ShareModel + @State private var externalExpanded = true + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + Divider() + footer + } + .background(Color(.systemBackground)) + } + + private var header: some View { + HStack { + Button("Cancel", action: model.onCancel) + Spacer() + Text("Upload to Noo").font(.headline) + Spacer() + // Balances Cancel so the title stays centred. + Button("Cancel", action: {}).hidden() + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + @ViewBuilder + private var content: some View { + switch model.stage { + case .preparing: + centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") } + case .uploading: + centered { + VStack(spacing: 8) { + ProgressView() + Text("Uploading \(model.fileCountText) in the background").font(.subheadline) + } + } + case .noAccount: + centered { + Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding() + } + case .failed(let message): + centered { + Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding() + } + case .chooseAccount: + accountList + case .picking: + folderList + } + } + + private var accountList: some View { + List { + Section( + header: Text("Upload \(model.fileCountText) to which account?"), + footer: model.notice.map { Text($0) } + ) { + ForEach(model.accounts) { account in + Button { + Task { await model.select(account) } + } label: { + HStack { + Label(account.displayName, systemImage: "person.crop.circle") + Spacer() + if account.id == model.activeAccountId { + Text("Active").font(.caption).foregroundColor(.secondary) + } + } + } + } + } + } + .listStyle(.insetGrouped) + } + + // MARK: Folders + + private var folderList: some View { + List { + Section(header: accountHeader, footer: model.notice.map { Text($0) }) { + filterRow + if model.path != "/" { + Button { + Task { await model.openParent() } + } label: { + Label("Up to \(parentName)", systemImage: "arrow.turn.left.up") + } + } + if model.isLoadingFolders { + HStack { Spacer(); ProgressView(); Spacer() } + } else if let error = model.folderError { + Text(error).foregroundColor(.secondary) + } else if model.internalFolders.isEmpty && model.externalFolders.isEmpty { + Text("No folders here").foregroundColor(.secondary) + } else { + ForEach(model.internalFolders, id: \.path) { folderRow($0) } + } + } + // "All" keeps external storage in its own group, like the app's list. + if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty { + Section { + DisclosureGroup(isExpanded: $externalExpanded) { + ForEach(model.externalFolders, id: \.path) { folderRow($0) } + } label: { + Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive") + } + } + } + } + .listStyle(.insetGrouped) + } + + private func folderRow(_ folder: DavFolder) -> some View { + Button { + Task { await model.open(folder.path) } + } label: { + Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder") + } + } + + /// The two toggles, as menus - Hidden folders and External storage each + /// start at the app's setting and can be changed for this share. + private var filterRow: some View { + HStack(spacing: 8) { + Menu { + Picker("Hidden folders", selection: hiddenBinding) { + Text("Hide hidden folders").tag(HiddenFilter.hide) + Text("Only hidden folders").tag(HiddenFilter.only) + Text("Show all").tag(HiddenFilter.include) + } + } label: { + chip("eye", "Hidden", hiddenTitle) + } + Menu { + Picker("External storage", selection: storageBinding) { + Text("Cloud only").tag(StorageFilter.cloud) + Text("Only external storage").tag(StorageFilter.external) + Text("All + external storage").tag(StorageFilter.all) + } + } label: { + chip("externaldrive", "Storage", storageTitle) + } + Spacer() + } + .buttonStyle(.borderless) + } + + private func chip(_ icon: String, _ name: String, _ value: String) -> some View { + Label("\(name): \(value)", systemImage: icon) + .font(.footnote.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(Color(.secondarySystemFill))) + } + + private var hiddenBinding: Binding { + Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } }) + } + + private var storageBinding: Binding { + Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } }) + } + + private var hiddenTitle: String { + switch model.hiddenFilter { + case .hide: return "Hide" + case .only: return "Only" + case .include: return "All" + } + } + + private var storageTitle: String { + switch model.storageFilter { + case .cloud: return "Cloud" + case .external: return "External" + case .all: return "All" + } + } + + private var accountHeader: some View { + HStack { + Text(model.selected?.displayName ?? "") + Spacer() + if model.canChangeAccount { + Button("Change account") { model.showAccounts() } + .font(.caption) + } + } + } + + private var parentName: String { + let parent = (model.path as NSString).deletingLastPathComponent + return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent + } + + private var footer: some View { + VStack(spacing: 8) { + if model.stage == .picking { + Button(action: model.onUpload) { + Text("Upload \(model.fileCountText) to \(model.currentFolderName)") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(model.isLoadingFolders) + } + if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount { + Button("Choose a folder later in Noo", action: model.onSaveForLater) + .font(.subheadline) + } + } + .padding() + } + + private func centered(@ViewBuilder _ content: () -> Content) -> some View { + VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity) + } +} diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000..4a05cc3 --- /dev/null +++ b/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,145 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers +import UserNotifications + +/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is +/// chosen *inside* the sheet - an extension can't open its host app - so this +/// shows a folder picker, then starts a background upload and closes: +/// +/// 1. Copies whatever was shared into the App Group (`SharedInbox`). +/// 2. Reads the accounts the app left in the shared Keychain +/// (`SharedAccountStore`) - asking which one first if there are several - +/// and lists folders over WebDAV (`DavClient`), honouring the app's +/// hidden-files filter and its unlock settings (`ShareModel`). +/// 3. "Upload" queues the files on a background session that outlives this +/// process (`ShareUpload`); the app is relaunched to finish and notify. +/// +/// With no account, or if the user prefers, the files are instead left in the +/// inbox for the app to offer its own destination picker next time it opens +/// (with a notification saying so). +final class ShareViewController: UIViewController { + private var model: ShareModel! + private var batchDirectory: URL? + + override func viewDidLoad() { + super.viewDidLoad() + model = ShareModel(shared: SharedAccountStore.load()) + model.onUpload = { [weak self] in self?.upload() } + model.onSaveForLater = { [weak self] in self?.saveForLater() } + model.onCancel = { [weak self] in self?.cancel() } + + let host = UIHostingController(rootView: SharePickerView(model: model)) + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + host.didMove(toParent: self) + + Task { await prepare() } + } + + // MARK: - Importing + + @MainActor + private func prepare() async { + guard let root = AppGroup.containerURL else { + model.stage = .failed("Noo isn't set up to receive files yet.") + return + } + let providers = + (extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? [] + + do { + let batch = try SharedInbox.makeBatchDirectory(in: root) + batchDirectory = batch + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) { + if let item = try? await copy(provider, into: batch) { model.items.append(item) } + } + } catch { + model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)") + return + } + + guard !model.items.isEmpty else { + model.stage = .failed("Noo can only receive files and photos.") + return + } + await model.start() + } + + /// `loadFileRepresentation`'s URL only lives for the duration of its + /// callback, so the copy happens inside it. + private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in + guard let url else { + return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + do { + let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent) + try FileManager.default.copyItem(at: url, to: destination) + let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0 + let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType + continuation.resume( + returning: SharedItem( + path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + // MARK: - Actions + + private func upload() { + guard let account = model.selected else { return } + do { + try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path) + model.stage = .uploading + complete(after: 1.2) + } catch { + model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)") + } + } + + private func saveForLater() { + guard let root = AppGroup.containerURL else { return cancel() } + do { + try SharedInbox.append(model.items, in: root) + notifyOpenNoo(count: model.items.count) + complete(after: 0) + } catch { + model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)") + } + } + + private func cancel() { + if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) } + extensionContext?.cancelRequest(withError: CocoaError(.userCancelled)) + } + + private func complete(after delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } + + private func notifyOpenNoo(count: Int) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = + count == 1 + ? "Open Noo to choose where to upload 1 file." + : "Open Noo to choose where to upload \(count) files." + content.sound = .default + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) + } +} diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift new file mode 100644 index 0000000..98f538a --- /dev/null +++ b/ios/Shared/DavFolders.swift @@ -0,0 +1,185 @@ +import Foundation + +/// A folder in the account's WebDAV tree. [path] is relative to the user's +/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root. +/// [isExternal] is true for an external-storage mount point (Nextcloud's +/// `nc:mount-type` = `external`) - only the mount's root carries it, its +/// children don't, so callers track mount roots to know what's inside one. +struct DavFolder: Equatable { + let name: String + let path: String + var isExternal = false +} + +/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud") +/// folders only (the default), only external-storage ones, or both. +enum StorageFilter: String { + case cloud, external, all + + init(raw: String) { + self = StorageFilter(rawValue: raw) ?? .cloud + } + + func shows(isExternal: Bool) -> Bool { + switch self { + case .cloud: return !isExternal + case .external: return isExternal + case .all: return true + } + } +} + +/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart): +/// leave dot-folders out (`hide`, the default), list only them (`only`), or +/// list everything (`include`). A folder counts as hidden when it - or any +/// folder above it - starts with a dot, exactly as in the app. +enum HiddenFilter: String { + case hide, only, include + + init(raw: String) { + self = HiddenFilter(rawValue: raw) ?? .hide + } + + static func isHidden(path: String) -> Bool { + path.split(separator: "/").contains { $0.hasPrefix(".") } + } + + func shows(path: String) -> Bool { + switch self { + case .hide: return !Self.isHidden(path: path) + case .only: return Self.isHidden(path: path) + case .include: return true + } + } +} + +/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no +/// networking), so it's unit-tested. Compiled into both targets. +enum DavFolderParser { + private struct Response { + var href = "" + var isCollection = false + var mountType = "" + } + + private final class Delegate: NSObject, XMLParserDelegate { + var responses: [Response] = [] + private var current: Response? + private var text = "" + + private func local(_ name: String) -> String { + name.split(separator: ":").last.map(String.init) ?? name + } + + func parser( + _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] + ) { + switch local(elementName) { + case "response": current = Response() + case "href", "mount-type": text = "" + case "collection": current?.isCollection = true + default: break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + text += string + } + + func parser( + _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String? + ) { + switch local(elementName) { + case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines) + case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines) + case "response": + if let current { responses.append(current) } + current = nil + default: break + } + } + } + + /// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms". + /// The server may live under a sub-path, so the files root is located by + /// its marker instead of assumed to start at the beginning. + static func relativePath(fromHref href: String) -> String? { + let decoded = href.removingPercentEncoding ?? href + let marker = "/remote.php/dav/files/" + guard let range = decoded.range(of: marker) else { return nil } + var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true) + guard !parts.isEmpty else { return nil } + parts.removeFirst() // the username + return "/" + parts.joined(separator: "/") + } + + /// Child folders of [currentPath], sorted by name, filtered by [hidden]. + /// The requested folder itself (which a Depth-1 PROPFIND also returns) is + /// always left out. + static func folders( + from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide + ) -> [DavFolder] { + let delegate = Delegate() + let parser = XMLParser(data: xml) + parser.delegate = delegate + parser.parse() + + let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression) + return delegate.responses + .filter(\.isCollection) + .compactMap { response -> DavFolder? in + guard let path = relativePath(fromHref: response.href), path != current else { return nil } + let name = (path as NSString).lastPathComponent + guard !name.isEmpty, hidden.shows(path: path) else { return nil } + return DavFolder(name: name, path: path, isExternal: response.mountType == "external") + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} + +/// Lists folders over WebDAV for the Share Extension's picker, in the +/// foreground (the sheet is on screen while it runs). +enum DavClient { + private static let body = """ + + + """ + + /// Every child folder, hidden and external ones included - the sheet's + /// toggles filter that list in memory, so changing one doesn't refetch. + /// Two accounts can live on the same server, so a request must be + /// authenticated by its own `Authorization` header alone - never by a + /// session cookie or credential a previous account's request left behind + /// in the shared stores, or the second account could be answered as the + /// first. + private static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: config) + }() + + static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { + guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) + else { throw TransferError(message: "Invalid server address.") } + var request = URLRequest(url: url) + request.httpMethod = "PROPFIND" + request.setValue("1", forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.setValue(account.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = body.data(using: .utf8) + request.timeoutInterval = 20 + + let (data, response) = try await session.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + NSLog("[DavClient] PROPFIND %@ as %@ -> %d", url.path, account.username, status) + guard status == 207 else { + throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).") + } + return DavFolderParser.folders(from: data, excluding: path, hidden: .include) + } +} diff --git a/ios/Shared/ShareUpload.swift b/ios/Shared/ShareUpload.swift new file mode 100644 index 0000000..eb68ff1 --- /dev/null +++ b/ios/Shared/ShareUpload.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Starts the Share Extension's uploads on a background `URLSession` that +/// outlives the extension. The extension process is torn down as soon as its +/// sheet closes; because the session has a `sharedContainerIdentifier` and +/// the same identifier is recreated by the app at launch +/// (`TransferManager.reconnect`), the *app* is what gets relaunched to +/// receive the results, post the summary notification and clean up. +/// Compiled into both targets. +enum ShareUpload { + static let sessionIdentifier = "dev.ayushya.noo.transfers.share" + + static func configuration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + config.sharedContainerIdentifier = AppGroup.identifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.waitsForConnectivity = true + // Authenticate by the request's own header only (see DavClient.session). + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + return config + } + + /// A `PUT` for [item] into [remoteFolder] - shared by the extension's + /// [enqueue] and the app's own uploads so both build requests identically. + static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? { + guard + let url = WebDAV.fileURL( + serverUrl: account.serverUrl, + username: account.username, + remotePath: WebDAV.join(remoteFolder, item.name) + ) + else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(account.authHeader, forHTTPHeaderField: "Authorization") + if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date { + request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime") + } + return request + } + + /// Queues every item (already copied into the App Group by the extension) + /// and returns once the system has accepted them. + static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws { + guard !items.isEmpty else { return } + let batch = UUID().uuidString + // Small files can finish while the extension is still on screen; for + // those this delegate counts them. Whatever outlives the extension is + // reported to the app instead (see `TransferManager`). + let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil) + + var tasks: [URLSessionUploadTask] = [] + for item in items { + guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else { + throw TransferError(message: "Invalid server address.") + } + let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path)) + task.taskDescription = + (try? JSONEncoder().encode( + TransferTaskInfo( + kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path) + )).flatMap { String(data: $0, encoding: .utf8) } + tasks.append(task) + } + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), + id: batch) + TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count) + tasks.forEach { $0.resume() } + session.finishTasksAndInvalidate() + } +} + +struct TransferError: LocalizedError { + let message: String + var errorDescription: String? { message } +} diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift new file mode 100644 index 0000000..031620d --- /dev/null +++ b/ios/Shared/SharedAccount.swift @@ -0,0 +1,161 @@ +import Foundation +import Security + +/// One signed-in account, as much of it as the Share Extension needs to list +/// folders and upload. Compiled into both targets. +struct SharedAccount: Codable, Equatable, Identifiable { + let id: String + let serverUrl: String + let username: String + /// `Basic ...` - the app password never leaves the Keychain as plain text. + let authHeader: String + /// Shown in the picker, e.g. "alice@cloud.example.com". + let displayName: String + /// The app's Files "hidden files" filter for this account - `hide` (the + /// default), `only` or `include` (see `HiddenFilter`). The share sheet + /// follows it instead of having a setting of its own. + let hiddenFilter: String + /// The app's storage scope for this account - `cloud` (the default), + /// `external` or `all` (see `StorageFilter`). + let storageScope: String + + init( + id: String, serverUrl: String, username: String, authHeader: String, displayName: String, + hiddenFilter: String = "hide", storageScope: String = "cloud" + ) { + self.id = id + self.serverUrl = serverUrl + self.username = username + self.authHeader = authHeader + self.displayName = displayName + self.hiddenFilter = hiddenFilter + self.storageScope = storageScope + } + + /// Tolerates data written before a field existed instead of failing the + /// whole decode (which would look like "signed out" to the extension). + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try c.decode(String.self, forKey: .id), + serverUrl: try c.decode(String.self, forKey: .serverUrl), + username: try c.decode(String.self, forKey: .username), + authHeader: try c.decode(String.self, forKey: .authHeader), + displayName: try c.decode(String.self, forKey: .displayName), + hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide", + storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud" + ) + } +} + +/// Everything the app publishes for the extension: every account that can +/// upload, which one is active in the app, and the app-lock settings the +/// extension has to honour. The app rewrites it whenever any of that changes. +struct SharedAccounts: Codable, Equatable { + var accounts: [SharedAccount] + var activeId: String? + /// Settings -> Security: the three independent locks. `loginLockEnabled` + /// (unlock to open the app) is carried for completeness; the extension's own + /// gates are the other two. + var loginLockEnabled: Bool + var lockAccountSwitching: Bool + var lockHiddenFiles: Bool + + /// The account the app is currently using, else the first one. + var active: SharedAccount? { + accounts.first { $0.id == activeId } ?? accounts.first + } + + /// Uploading to an account other than the active one is "switching" in + /// the app's terms, so it needs the same unlock. + var needsUnlockToSwitchAccount: Bool { lockAccountSwitching } + + /// Showing hidden folders needs the same unlock the app asks for when you + /// turn hidden files on. + var needsUnlockForHidden: Bool { lockHiddenFiles } + + /// What choosing [account] (with [hidden] folders to show) asks the user + /// to unlock. One action, one prompt - when both the account switch and + /// the hidden folders need it, a single authentication covers both. + func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock { + SelectionUnlock( + switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount, + showsHidden: hidden != .hide && needsUnlockForHidden) + } + + /// Turning hidden folders on (from `hide`) is what the app locks; going + /// back to `hide`, or between the two revealing modes, never asks. + func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool { + old == .hide && new != .hide && needsUnlockForHidden + } +} + +/// Keeps the [SharedAccounts] in a Keychain access group both the app and the +/// extension are entitled to (`keychain-access-groups`). The group's full id +/// carries the signing team's prefix, so it's injected into each target's +/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` + +/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes +/// then always agree on it, with or without a team. +enum SharedAccountStore { + private static let service = "dev.ayushya.noo.shared-accounts" + private static let account = "all" + + static var accessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String + } + + private static func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if let group = accessGroup, !group.isEmpty { + query[kSecAttrAccessGroup as String] = group + } + return query + } + + static func save(_ value: SharedAccounts) throws { + let data = try JSONEncoder().encode(value) + clear() + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func load() -> SharedAccounts? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data), + !decoded.accounts.isEmpty + else { return nil } + return decoded + } + + static func clear() { + SecItemDelete(baseQuery() as CFDictionary) + // The first version kept a single account under its own service name; + // don't leave that credential behind. + var legacy = baseQuery() + legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account" + legacy[kSecAttrAccount as String] = "active" + SecItemDelete(legacy as CFDictionary) + } +} + +/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`. +struct SelectionUnlock: Equatable { + let switchesAccount: Bool + let showsHidden: Bool + + var needsPrompt: Bool { switchesAccount || showsHidden } +} diff --git a/ios/Shared/SharedInbox.swift b/ios/Shared/SharedInbox.swift new file mode 100644 index 0000000..0082cba --- /dev/null +++ b/ios/Shared/SharedInbox.swift @@ -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 `/SharedInbox/`: `pending.json` (the manifest) and one +/// `/` 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 } + } +} diff --git a/ios/Shared/TransferNotifications.swift b/ios/Shared/TransferNotifications.swift new file mode 100644 index 0000000..32f4b89 --- /dev/null +++ b/ios/Shared/TransferNotifications.swift @@ -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) + } + } +} diff --git a/ios/Shared/TransferTypes.swift b/ios/Shared/TransferTypes.swift new file mode 100644 index 0000000..c7b284f --- /dev/null +++ b/ios/Shared/TransferTypes.swift @@ -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 + } +} diff --git a/ios/Shared/WebDAV.swift b/ios/Shared/WebDAV.swift new file mode 100644 index 0000000..aefbae2 --- /dev/null +++ b/ios/Shared/WebDAV.swift @@ -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 + } + + /// `/remote.php/dav/files//` - [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 + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 69a5951..a6126b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'providers/shares_controller.dart'; import 'providers/sync_status_controller.dart'; import 'providers/trash_controller.dart'; import 'services/pick_intent_service.dart'; +import 'services/share_account_service.dart'; import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; import 'theme/design_tokens.dart'; @@ -31,12 +32,13 @@ import 'views/share_upload_view.dart'; import 'widgets/app_drawer.dart'; import 'widgets/app_tab_view_builder.dart'; import 'widgets/app_top_bar.dart'; +import 'widgets/avatar_menu.dart'; import 'widgets/bottom_nav_bar.dart'; import 'widgets/create_menu.dart'; import 'widgets/noo/core/noo_avatar.dart'; import 'widgets/noo/core/noo_button.dart'; import 'widgets/noo/core/noo_fab.dart'; -import 'widgets/noo/nav/noo_nav_style.dart'; +import 'widgets/noo/nav/noo_bottom_bar.dart'; import 'widgets/noo/nav/noo_sidebar.dart'; import 'widgets/noo/nav/noo_toolbar.dart'; import 'widgets/noo/noo_layout.dart'; @@ -58,7 +60,14 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => ConnectivityController()), ChangeNotifierProvider( - create: (context) => SessionController(context.read()), + create: (context) { + final session = SessionController(context.read()); + // iOS's Share Extension has no Flutter engine; sign-out must wipe + // what it was given. (Publishing happens in the shell - see + // ShareAccountSync - once there's a FilesController to read.) + session.addAccountClearedListener(ShareAccountService.clear); + return session; + }, ), ChangeNotifierProvider(create: (_) => SettingsController()), // Before SyncStatusController, which follows its Files Cache rule @@ -202,9 +211,11 @@ class MainShellView extends StatefulWidget { class _MainShellViewState extends State { late AppTab _currentTab; + final Set _selectionActiveTabs = {}; late final Map _scrollControllers; StreamSubscription>? _shareSub; StreamSubscription? _pickSub; + ShareAccountSync? _shareAccountSync; @override void initState() { @@ -226,6 +237,13 @@ class _MainShellViewState extends State { ShareIntentService.getInitialShare().then(_handleSharedFiles); _shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles); + // iOS's Share Extension picks accounts/folders on its own, so it's kept + // supplied with the accounts, lock settings and hidden-files filters. + _shareAccountSync = ShareAccountSync( + context.read(), + context.read(), + )..start(); + // Same cold-start-vs-already-running split as the share intent above: // another app may have launched Noo as its GET_CONTENT picker. PickIntentService.getPickRequest().then((request) { @@ -310,6 +328,7 @@ class _MainShellViewState extends State { } _shareSub?.cancel(); _pickSub?.cancel(); + _shareAccountSync?.dispose(); super.dispose(); } @@ -413,24 +432,41 @@ class _MainShellViewState extends State { // null`). final tabStack = Stack( children: [ - IndexedStack( - index: selectedIndex, - children: displayTabs - .map( - (tab) => buildAppTabView( - tab, - _scrollControllers[tab]!, - topBar: isDesktop || pickRequest != null - ? null - : AppTopBar( - style: navStyle, - tab: tab, - searchInBottomBar: showBottomBarSearch, - navMenuStyle: settings.navMenuStyle, - ), - ), - ) - .toList(), + AvatarNavigationBody( + child: IndexedStack( + index: selectedIndex, + children: displayTabs + .map( + (tab) => buildAppTabView( + tab, + _scrollControllers[tab]!, + onSelectionChanged: (selecting) { + if (!mounted || + selecting == _selectionActiveTabs.contains(tab)) { + return; + } + setState(() { + if (selecting) { + _selectionActiveTabs.add(tab); + } else { + _selectionActiveTabs.remove(tab); + } + }); + }, + topBar: isDesktop || pickRequest != null + ? null + : AppTopBar( + style: navStyle, + tab: tab, + searchInBottomBar: showBottomBarSearch, + navMenuStyle: settings.navMenuStyle, + avatarPosition: settings.avatarPosition, + uploadButtonStyle: settings.fabStyle, + ), + ), + ) + .toList(), + ), ), if (pick.isDownloadingForPick) const _PickingProgressOverlay(), ], @@ -526,15 +562,21 @@ class _MainShellViewState extends State { settings.navMenuStyle == NooNavMenuStyle.drawer, // Floating needs the body to draw behind the bar's own transparent // margin (see NooBottomBarStyle's doc comment) instead of stopping - // short of it like attached does. - extendBody: bottomBarStyle == NooBottomBarStyle.floating, - // Android-only extended Upload FAB - iOS uses the top bar's `plus` + // short of it like attached does; a frosted bar needs it too, or + // there'd be nothing behind it to blur. + extendBody: NooBottomBar.drawsBehindBody( + bottomBarStyle, + settings.bottomBarFrosted, + ), + // Android-only extended Upload FAB - iOS uses the top bar's Upload // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, // rather than the Scaffold popping it fully in/out on every tab // switch - see NooFab's [collapsed]. floatingActionButton: - pickRequest == null && navStyle == NooNavStyle.android + pickRequest == null && + navStyle == NooNavStyle.android && + !_selectionActiveTabs.contains(selectedTab) ? NooFab( collapsed: switch (settings.fabStyle) { FabStyle.auto => !canUpload, @@ -545,13 +587,35 @@ class _MainShellViewState extends State { onTap: () => showCreateMenu(context), ) : null, - body: tabStack, + body: TweenAnimationBuilder( + tween: Tween( + end: switch (settings.fabStyle) { + FabStyle.auto => canUpload ? 1.0 : 0.0, + FabStyle.mini => 0.0, + FabStyle.expanded => 1.0, + }, + ), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : NooMotion.base, + curve: NooMotion.ease, + child: tabStack, + builder: (context, progress, child) => + UploadButtonAnimation(progress: progress, child: child!), + ), bottomNavigationBar: BottomNavBar( style: navStyle, barStyle: bottomBarStyle, + frosted: settings.bottomBarFrosted, + frostedBlur: settings.bottomBarFrostedBlur, + frostedOpacity: settings.bottomBarFrostedOpacity, tabs: pinnedTabs, selectedIndex: pinnedIndex, onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, + avatarInBottomBar: + pickRequest == null && + settings.navMenuStyle == NooNavMenuStyle.avatarMenu && + settings.avatarPosition == AvatarPosition.bottom, onDestinationSelected: (index) { final tappedTab = pinnedTabs[index]; if (tappedTab == _currentTab) { @@ -591,7 +655,7 @@ class _MainShellViewState extends State { onPopInvokedWithResult: (didPop, result) { if (!didPop && pickRequest != null) pick.cancelPick(); }, - child: scaffold, + child: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold), ); } } diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index e3309b5..486a1e1 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -9,10 +9,16 @@ import 'session_controller.dart'; enum FileSortField { name, dateCreated, dateModified, size } -/// Which storage a listing shows — always exactly one, like the list/grid -/// view toggle, not an optional filter. Shared across Files/Photos/ -/// Favorites (see [applyCommonFilters]'s doc comment). -enum StorageScope { cloud, external } +/// Which storage a listing shows: [cloud] (the default - external mounts +/// hidden), only [external] ones, or [all] of both (Files shows the +/// external items in their own collapsible section then). Shared across +/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment). +enum StorageScope { cloud, external, all } + +/// Which dotfile/dotfolder items a listing shows: [hide] them (the +/// default), show [only] them, or [include] them alongside everything else. +/// Files and Photos each keep their own (see [applyCommonFilters]). +enum HiddenFilesFilter { hide, only, include } /// The Files tab's files/folders/both filter — independent of and applied /// after [StorageScope]/favorites/hidden filtering. @@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier static const _prefStorageScope = 'ui_storage_scope'; static const _prefFilesTypeFilter = 'ui_files_type_filter'; static const _prefShowHiddenFiles = 'ui_show_hidden'; + static const _prefHiddenFilter = 'ui_hidden_filter'; static const _prefFolderSort = 'ui_folder_sort'; static const prefCachePolicy = 'ui_cache_policy'; static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; @@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier bool _isGridView = false; StorageScope _storageScope = StorageScope.cloud; FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all; - bool _showHiddenFiles = false; + HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide; final Map _folderSortField = {}; final Map _folderSortAscending = {}; @@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier bool get isGridView => _isGridView; StorageScope get storageScope => _storageScope; FilesTypeFilter get filesTypeFilter => _filesTypeFilter; - bool get showHiddenFiles => _showHiddenFiles; + HiddenFilesFilter get hiddenFilter => _hiddenFilter; FileSortField get filesSortField => sortFieldFor(_currentFolderPath); bool get filesSortAscending => sortAscendingFor(_currentFolderPath); @@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier List applyCommonFilters( List source, { required bool showFavoritesOnly, - required bool showHidden, + required HiddenFilesFilter hidden, bool applyStorageScope = true, }) { var filtered = source; if (showFavoritesOnly) { filtered = filtered.where((item) => item.isFavorite).toList(); } - if (!showHidden) { - filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); + switch (hidden) { + case HiddenFilesFilter.hide: + filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); + case HiddenFilesFilter.only: + filtered = filtered.where(_isHiddenItem).toList(); + case HiddenFilesFilter.include: + break; } if (applyStorageScope) { - filtered = filtered - .where( - (item) => _storageScope == StorageScope.external - ? item.isExternalStorage - : !item.isExternalStorage, - ) - .toList(); + switch (_storageScope) { + case StorageScope.cloud: + filtered = filtered.where((i) => !i.isExternalStorage).toList(); + case StorageScope.external: + filtered = filtered.where((i) => i.isExternalStorage).toList(); + case StorageScope.all: + break; + } } return filtered; } @@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier // Files itself doesn't filter by favorite - that's the dedicated // Favorites tab's job. showFavoritesOnly: false, - showHidden: _showHiddenFiles, + hidden: _hiddenFilter, applyStorageScope: applyStorageScope, ); switch (_filesTypeFilter) { @@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier _isGridView = false; _storageScope = StorageScope.cloud; _filesTypeFilter = FilesTypeFilter.all; - _showHiddenFiles = false; + _hiddenFilter = HiddenFilesFilter.hide; _cachePolicy = defaultCachePolicy; _cacheIntervalMinutes = defaultCacheIntervalMinutes; _isLoading = false; @@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier (f) => f.name == filesTypeFilterName, orElse: () => FilesTypeFilter.all, ); - _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; + _hiddenFilter = loadHiddenFilter( + prefs, + k(_prefHiddenFilter), + k(_prefShowHiddenFiles), + ); final folderSortJson = prefs.getString(k(_prefFolderSort)); if (folderSortJson != null) { @@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier ); } - /// Only *enabling* hidden-files visibility is gated - hiding them again - /// never exposes anything, so that direction is always allowed instantly. - Future toggleShowHiddenFiles() async { - if (!_showHiddenFiles) { + /// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it + /// never exposes anything, so that direction is always allowed instantly + /// (and switching between the two revealing modes needs no second prompt). + Future setHiddenFilter(HiddenFilesFilter filter) async { + if (_hiddenFilter == filter) return; + if (_hiddenFilter == HiddenFilesFilter.hide) { if (!await session.passGate( session.lockHiddenFiles, 'Unlock to show hidden files', @@ -650,14 +669,61 @@ class FilesController extends ChangeNotifier return; } } - _showHiddenFiles = !_showHiddenFiles; + _hiddenFilter = filter; notifyListeners(); _persistAccountPref( - _prefShowHiddenFiles, - (p, key) => p.setBool(key, _showHiddenFiles), + _prefHiddenFilter, + (p, key) => p.setString(key, filter.name), ); } + /// The storage scope saved for [accountId] (see [savedHiddenFilter]). + static StorageScope savedStorageScope( + SharedPreferences prefs, + String Function(String accountId, String baseKey) accountPrefKey, + String accountId, + ) { + final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope)); + return StorageScope.values.firstWhere( + (s) => s.name == name, + orElse: () => StorageScope.cloud, + ); + } + + /// The hidden-files filter saved for [accountId] - not just the active + /// account's, which is all [hiddenFilter] holds. For iOS's Share Extension, + /// which follows each account's own setting. + static HiddenFilesFilter savedHiddenFilter( + SharedPreferences prefs, + String Function(String accountId, String baseKey) accountPrefKey, + String accountId, + ) => loadHiddenFilter( + prefs, + accountPrefKey(accountId, _prefHiddenFilter), + accountPrefKey(accountId, _prefShowHiddenFiles), + ); + + /// Reads a persisted [HiddenFilesFilter], falling back to the old + /// show-hidden bool (`true` meant everything incl. hidden) so a pref + /// saved before the three-way filter keeps its meaning. [key]s are + /// already account-namespaced by the caller. + static HiddenFilesFilter loadHiddenFilter( + SharedPreferences prefs, + String key, + String legacyBoolKey, + ) { + final name = prefs.getString(key); + if (name != null) { + return HiddenFilesFilter.values.firstWhere( + (f) => f.name == name, + orElse: () => HiddenFilesFilter.hide, + ); + } + return (prefs.getBool(legacyBoolKey) ?? false) + ? HiddenFilesFilter.include + : HiddenFilesFilter.hide; + } + void _persistFolderSort() { final combined = {}; for (final path in { diff --git a/lib/providers/offline_controller.dart b/lib/providers/offline_controller.dart index 3939e7e..d744bb2 100644 --- a/lib/providers/offline_controller.dart +++ b/lib/providers/offline_controller.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import '../models/nextcloud_item.dart'; import '../services/sync_service.dart'; import 'files_controller.dart'; @@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier notifyListeners(); try { - _syncBasePath ??= (await getExternalStorageDirectory())?.path; + _syncBasePath ??= (await SyncService.baseDirectory())?.path; // A sync can delete the very folder being browsed (deleted on the // server) - step out to the nearest ancestor that still exists rather // than showing a phantom empty folder. @@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier Future localPathFor(NextcloudItem item) async { final accountId = session.activeAccountId; if (accountId == null) return null; - final base = await getExternalStorageDirectory(); + final base = await SyncService.baseDirectory(); if (base == null) return null; final relPath = item.path.startsWith('/') ? item.path.substring(1) @@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier String accountId, String folderPath, ) async { - final base = await getExternalStorageDirectory(); + final base = await SyncService.baseDirectory(); if (base == null) return []; final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', ''); final dir = Directory(p.join(base.path, 'sync', accountId, relFolder)); diff --git a/lib/providers/photos_controller.dart b/lib/providers/photos_controller.dart index b5b4665..80f1e48 100644 --- a/lib/providers/photos_controller.dart +++ b/lib/providers/photos_controller.dart @@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier { static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; + static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos'; static const _prefSortField = 'ui_sort_field'; static const _prefSortAscending = 'ui_sort_ascending'; @@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier { String? _errorMessage; bool _showFavoritesOnly = false; - bool _showHidden = false; + HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide; FileSortField _sortField = FileSortField.name; bool _sortAscending = true; @@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier { bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; bool get showFavoritesOnly => _showFavoritesOnly; - bool get showHidden => _showHidden; + HiddenFilesFilter get hiddenFilter => _hiddenFilter; FileSortField get sortField => _sortField; bool get sortAscending => _sortAscending; @@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier { final filtered = files.applyCommonFilters( media, showFavoritesOnly: _showFavoritesOnly, - showHidden: _showHidden, + hidden: _hiddenFilter, )..sort((a, b) => _compare(a, b)); return _sortAscending ? filtered : filtered.reversed.toList(); } @@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier { String k(String base) => session.accountStore.accountPrefKey(id, base); _showFavoritesOnly = prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false; - _showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; + _hiddenFilter = FilesController.loadHiddenFilter( + prefs, + k(_prefHiddenFilterPhotos), + k(_prefShowHiddenPhotos), + ); final sortFieldName = prefs.getString(k(_prefSortField)); _sortField = FileSortField.values.firstWhere( (f) => f.name == sortFieldName, @@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier { ); } - Future toggleShowHidden() async { - if (!_showHidden) { + /// Gated the same way as [FilesController.setHiddenFilter]. + Future setHiddenFilter(HiddenFilesFilter filter) async { + if (_hiddenFilter == filter) return; + if (_hiddenFilter == HiddenFilesFilter.hide) { if (!await session.passGate( session.lockHiddenFiles, 'Unlock to show hidden files', @@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier { return; } } - _showHidden = !_showHidden; + _hiddenFilter = filter; notifyListeners(); _persistAccountPref( - _prefShowHiddenPhotos, - (p, key) => p.setBool(key, _showHidden), + _prefHiddenFilterPhotos, + (p, key) => p.setString(key, filter.name), ); } diff --git a/lib/providers/session_controller.dart b/lib/providers/session_controller.dart index 2504d80..42f8636 100644 --- a/lib/providers/session_controller.dart +++ b/lib/providers/session_controller.dart @@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { /// session-restore that failed, e.g. transient network trouble at cold /// start), this still retries rather than no-op, since that's exactly the /// case LoginView's "Continue as" tile exists to recover from. Gated - /// behind login lock when [lockAccountSwitching] is on. Returns whether + /// behind its own unlock when [lockAccountSwitching] is on. Returns whether /// the account ended up logged in, so callers (LoginView's saved-account /// tile) can surface a failure - e.g. a stored app password that no /// longer works and needs the account removed/re-added. @@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Turns login lock off, along with both of its sub-toggles (meaningless - /// once the base lock is gone). Requires a successful auth first, same as - /// turning it on - otherwise anyone with momentary access to an unlocked - /// phone could just switch it off. + /// Turns login lock off. Requires a successful auth first, same as turning + /// it on - otherwise anyone with momentary access to an unlocked phone + /// could just switch it off. Only this lock: the account-switching and + /// hidden-files locks are independent and stay as they are. Future disableLoginLock() async { if (!_loginLockEnabled) return true; final confirmed = await AppLockService.authenticate( @@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { ); if (!confirmed) return false; _loginLockEnabled = false; - _lockAccountSwitching = false; - _lockHiddenFiles = false; _isUnlocked = false; notifyListeners(); - prefsFuture.then((p) { - p.setBool(_prefLoginLockEnabled, false); - p.setBool(_prefLockAccountSwitching, false); - p.setBool(_prefLockHiddenFiles, false); - }); + prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false)); return true; } - void setLockAccountSwitching(bool value) { - if (!_loginLockEnabled) return; + /// Turns the "unlock to switch accounts" gate on or off. Independent of + /// login lock. Both directions need a successful auth - enabling proves the + /// device can authenticate at all (a gate nobody can pass would lock the + /// user out of switching), disabling stops anyone with momentary access to + /// an unlocked phone from just removing it. Returns whether it changed. + Future setLockAccountSwitching(bool value) async { + if (value == _lockAccountSwitching) return true; + if (!await _confirmLockChange( + value, + 'Confirm to lock account switching', + 'Confirm to unlock account switching', + )) { + return false; + } _lockAccountSwitching = value; notifyListeners(); prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value)); + return true; } - void setLockHiddenFiles(bool value) { - if (!_loginLockEnabled) return; + /// Same as [setLockAccountSwitching], for revealing hidden files. + Future setLockHiddenFiles(bool value) async { + if (value == _lockHiddenFiles) return true; + if (!await _confirmLockChange( + value, + 'Confirm to lock hidden files', + 'Confirm to unlock hidden files', + )) { + return false; + } _lockHiddenFiles = value; notifyListeners(); prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value)); + return true; + } + + Future _confirmLockChange( + bool enabling, + String enableReason, + String disableReason, + ) async { + if (enabling && !await AppLockService.isDeviceSupported()) return false; + return AppLockService.authenticate(enabling ? enableReason : disableReason); } /// Called by the lock screen. Returns whether it actually unlocked. @@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { return success; } - /// Prompts for auth if [gate] is on and login lock is configured; - /// returns true immediately (no prompt) otherwise. Shared by the - /// account-switching gate above and [FilesController]'s/`PhotosController`'s - /// hidden-files gates. + /// Prompts for auth if [gate] is on; returns true immediately (no prompt) + /// otherwise. Each gate stands on its own - it doesn't depend on login lock + /// being on. Shared by the account-switching gate above and + /// [FilesController]'s/`PhotosController`'s hidden-files gates. Future passGate(bool gate, String reason) async { - if (!_loginLockEnabled || !gate) return true; + if (!gate) return true; return AppLockService.authenticate(reason); } diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 3a1340b..83211d3 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -7,13 +7,35 @@ import '../widgets/noo/nav/noo_nav_style.dart'; /// What swiping a Files list-view item left/right does, user-configurable /// in Settings. -enum SwipeAction { none, favorite, delete, share } +enum SwipeAction { + none(null), + favorite(SelectionActionKind.favorite), + delete(SelectionActionKind.delete), + share(SelectionActionKind.share), + download(SelectionActionKind.download), + copy(SelectionActionKind.copy), + move(SelectionActionKind.move), + rename(SelectionActionKind.rename), + sync(SelectionActionKind.sync), + details(SelectionActionKind.details); -/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and + const SwipeAction(this.selectionKind); + + final SelectionActionKind? selectionKind; + + String get label => selectionKind?.label ?? 'None'; +} + +/// How the mobile Upload button is sized: [auto] expands on Files/Photos and /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] /// always shows the label. enum FabStyle { auto, mini, expanded } +/// Where bulk selection actions float within the content pane. +enum SelectionBarPosition { top, bottom } + +enum AvatarPosition { top, bottom } + /// Thumb/track presets for the video player's seek bar, matching the four /// combinations offered by other Material You media players: a Material 3 /// slider-style thumb ([classic]), an animated travelling wave with a round @@ -21,6 +43,30 @@ enum FabStyle { auto, mini, expanded } /// animated wave with a tick-mark thumb ([squiggly]). enum MediaProgressBarStyle { classic, wavy, slim, squiggly } +/// The frosted-glass bottom bar's blur sigma and surface opacity, in three +/// ready-made strengths (Settings, Appearance). Moving either slider off all +/// three leaves no preset selected (a custom mix). +enum FrostedGlassPreset { + less(blur: 10, opacity: 0.88, label: 'Less'), + standard(blur: 20, opacity: 0.72, label: 'Default'), + more(blur: 32, opacity: 0.55, label: 'More'); + + final double blur; + final double opacity; + final String label; + + const FrostedGlassPreset({ + required this.blur, + required this.opacity, + required this.label, + }); +} + +const double minFrostedBlur = 0; +const double maxFrostedBlur = 40; +const double minFrostedOpacity = 0.3; +const double maxFrostedOpacity = 1.0; + /// Global (not per-account) UI settings: theme, bottom-nav appearance, tab /// configuration, swipe actions. Independent of login state - these mean /// the same thing whether any account is signed in or not, unlike @@ -31,6 +77,7 @@ class SettingsController extends ChangeNotifier { static const _prefUseDynamicColor = 'ui_use_dynamic_color'; static const _prefSeedColor = 'ui_seed_color'; static const _prefTabOrder = 'ui_tab_order'; + static const _prefSelectionBarPosition = 'ui_selection_bar_position'; static const _prefSelectionActionOrder = 'ui_selection_action_order'; static const _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefDefaultTab = 'ui_default_tab'; @@ -38,6 +85,10 @@ class SettingsController extends ChangeNotifier { static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefFabStyle = 'ui_fab_style'; static const _prefBottomBarStyle = 'ui_bottom_bar_style'; + static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted'; + static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur'; + static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity'; + static const _prefAvatarPosition = 'ui_avatar_position'; static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; @@ -51,12 +102,17 @@ class SettingsController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; + AvatarPosition _avatarPosition = AvatarPosition.top; + bool _bottomBarFrosted = false; + double _frostedBlur = FrostedGlassPreset.standard.blur; + double _frostedOpacity = FrostedGlassPreset.standard.opacity; bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; bool _tapTabToScrollTop = true; + SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top; List _tabOrder = AppTab.values.toList(); Set _hiddenTabs = {}; @@ -95,12 +151,23 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; + bool get bottomBarFrosted => _bottomBarFrosted; + double get bottomBarFrostedBlur => _frostedBlur; + double get bottomBarFrostedOpacity => _frostedOpacity; + + /// The preset the current blur/opacity match exactly, or null when the + /// sliders have been moved to something custom. + FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values + .where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity) + .firstOrNull; + AvatarPosition get avatarPosition => _avatarPosition; NooNavMenuStyle get navMenuStyle => _navMenuStyle; bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; bool get tapTabToScrollTop => _tapTabToScrollTop; + SelectionBarPosition get selectionBarPosition => _selectionBarPosition; /// The cap a screen should actually enforce for the *regular*, user- /// reorderable tabs - one below [defaultMaxVisibleTabs] while @@ -141,6 +208,11 @@ class SettingsController extends ChangeNotifier { orElse: () => ThemeMode.system, ); } + _selectionBarPosition = SelectionBarPosition.values.firstWhere( + (position) => + position.name == prefs.getString(_prefSelectionBarPosition), + orElse: () => SelectionBarPosition.top, + ); final bottomBarStyleName = prefs.getString(_prefBottomBarStyle); if (bottomBarStyleName != null) { _bottomBarStyle = NooBottomBarStyle.values.firstWhere( @@ -148,6 +220,10 @@ class SettingsController extends ChangeNotifier { orElse: () => _bottomBarStyle, ); } + _avatarPosition = AvatarPosition.values.firstWhere( + (position) => position.name == prefs.getString(_prefAvatarPosition), + orElse: () => AvatarPosition.top, + ); final navMenuStyleName = prefs.getString(_prefNavMenuStyle); if (navMenuStyleName != null) { _navMenuStyle = NooNavMenuStyle.values.firstWhere( @@ -155,6 +231,17 @@ class SettingsController extends ChangeNotifier { orElse: () => _navMenuStyle, ); } + _bottomBarFrosted = + prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted; + _frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp( + minFrostedBlur, + maxFrostedBlur, + ); + _frostedOpacity = + (prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp( + minFrostedOpacity, + maxFrostedOpacity, + ); _searchInBottomBar = prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = @@ -306,6 +393,22 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); } + void setAvatarPosition(AvatarPosition position) { + if (_avatarPosition == position) return; + _avatarPosition = position; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefAvatarPosition, position.name)); + } + + void setSelectionBarPosition(SelectionBarPosition position) { + if (_selectionBarPosition == position) return; + _selectionBarPosition = position; + notifyListeners(); + _prefsFuture.then( + (p) => p.setString(_prefSelectionBarPosition, position.name), + ); + } + void setBottomBarStyle(NooBottomBarStyle style) { if (_bottomBarStyle == style) return; _bottomBarStyle = style; @@ -313,6 +416,34 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); } + void setFrostedBlur(double value) { + final v = value.clamp(minFrostedBlur, maxFrostedBlur); + if (_frostedBlur == v) return; + _frostedBlur = v; + notifyListeners(); + _prefsFuture.then((p) => p.setDouble(_prefFrostedBlur, v)); + } + + void setFrostedOpacity(double value) { + final v = value.clamp(minFrostedOpacity, maxFrostedOpacity); + if (_frostedOpacity == v) return; + _frostedOpacity = v; + notifyListeners(); + _prefsFuture.then((p) => p.setDouble(_prefFrostedOpacity, v)); + } + + void setFrostedPreset(FrostedGlassPreset preset) { + setFrostedBlur(preset.blur); + setFrostedOpacity(preset.opacity); + } + + void setBottomBarFrosted(bool value) { + if (_bottomBarFrosted == value) return; + _bottomBarFrosted = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value)); + } + void setNavMenuStyle(NooNavMenuStyle style) { if (_navMenuStyle == style) return; _navMenuStyle = style; diff --git a/lib/services/app_lock_service.dart b/lib/services/app_lock_service.dart index 8a205d7..7a0cda6 100644 --- a/lib/services/app_lock_service.dart +++ b/lib/services/app_lock_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:local_auth/local_auth.dart'; /// Thin wrapper around `local_auth`. Deliberately doesn't implement its own @@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart'; class AppLockService { static final LocalAuthentication _auth = LocalAuthentication(); + /// Replace the platform prompt in tests (null = the real one). + @visibleForTesting + static Future Function(String reason)? debugAuthenticate; + + @visibleForTesting + static Future Function()? debugIsDeviceSupported; + /// Whether this device can do *some* form of local auth - biometric /// enrolled, or at minimum a device PIN/pattern/password set up. static Future isDeviceSupported() async { + if (debugIsDeviceSupported != null) return debugIsDeviceSupported!(); try { final canCheckBiometrics = await _auth.canCheckBiometrics; if (canCheckBiometrics) return true; @@ -24,6 +33,7 @@ class AppLockService { /// throws) on cancellation, failure, or any platform error, so callers /// can treat every non-true result the same way: stay locked/blocked. static Future authenticate(String reason) async { + if (debugAuthenticate != null) return debugAuthenticate!(reason); try { return await _auth.authenticate( localizedReason: reason, diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 146a70c..348517a 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -38,6 +38,9 @@ class DownloadService { .toList(), ); - await _channel.invokeMethod('startDownload', {...args, 'files': filesJson}); + await invokeOrExplain(_channel, 'startDownload', 'Downloading', { + ...args, + 'files': filesJson, + }); } } diff --git a/lib/services/native_channel.dart b/lib/services/native_channel.dart index ef12b69..b2de9f1 100644 --- a/lib/services/native_channel.dart +++ b/lib/services/native_channel.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import '../providers/session_controller.dart'; /// The "get this account's auth header, or bail" guard every native- @@ -21,3 +22,57 @@ Map baseChannelArgs(SessionController session) { 'authHeader': requireAuthHeader(session), }; } + +/// Thrown by user-triggered native services (upload, download, sync now...) +/// on a platform whose native side isn't implemented yet - the message is +/// shown to the user as-is, e.g. in a snackbar. +class NativeServiceUnavailable implements Exception { + final String feature; + + const NativeServiceUnavailable(this.feature); + + @override + String toString() => "$feature isn't available on this platform yet."; +} + +/// Calls [method], treating a missing native implementation (no +/// `MethodChannel` handler registered, as on iOS until each service is +/// built there) as "nothing to report": returns null instead of throwing +/// `MissingPluginException`. For cold-start checks, scheduling and other +/// calls nobody is waiting on. +Future invokeIfAvailable( + MethodChannel channel, + String method, [ + Object? arguments, +]) async { + try { + return await channel.invokeMethod(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 invokeOrExplain( + MethodChannel channel, + String method, + String feature, [ + Object? arguments, +]) async { + try { + return await channel.invokeMethod(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 quietEvents(EventChannel channel, T Function(dynamic) convert) { + return channel + .receiveBroadcastStream() + .handleError((Object _) {}, test: (e) => e is MissingPluginException) + .map(convert); +} diff --git a/lib/services/pick_intent_service.dart b/lib/services/pick_intent_service.dart index 7781b5d..f9e4852 100644 --- a/lib/services/pick_intent_service.dart +++ b/lib/services/pick_intent_service.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; import '../models/pick_request.dart'; +import 'native_channel.dart'; /// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling /// (mirrors `ShareIntentService`'s split between a cold-start check and a @@ -14,7 +15,8 @@ class PickIntentService { /// Non-null if the app was launched cold as another app's file/photo /// picker. static Future getPickRequest() async { - final result = await _methodChannel.invokeMethod>( + final result = await invokeIfAvailable>( + _methodChannel, 'getPickRequest', ); return result == null ? null : PickRequest.fromMap(result); @@ -23,7 +25,8 @@ class PickIntentService { /// Emits whenever the app is asked to act as a picker while already /// running. static Stream get onNewPickRequest { - return _newPickChannel.receiveBroadcastStream().map( + return quietEvents( + _newPickChannel, (event) => PickRequest.fromMap(event as Map), ); } @@ -34,7 +37,7 @@ class PickIntentService { List localPaths, List mimeTypes, ) { - return _methodChannel.invokeMethod('finishPick', { + return invokeIfAvailable(_methodChannel, 'finishPick', { 'paths': localPaths, 'mimeTypes': mimeTypes, }); @@ -42,6 +45,6 @@ class PickIntentService { /// Backs out of picking mode with no result, closing the picker. static Future cancelPick() { - return _methodChannel.invokeMethod('cancelPick'); + return invokeIfAvailable(_methodChannel, 'cancelPick'); } } diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart new file mode 100644 index 0000000..fccd277 --- /dev/null +++ b/lib/services/share_account_service.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import '../providers/files_controller.dart'; +import '../providers/session_controller.dart'; +import 'native_channel.dart'; + +/// One account as the iOS Share Extension needs it - plain data, so the +/// payload builder is testable without a session. +class ShareAccountEntry { + final String id; + final String serverUrl; + final String username; + final String password; + + /// The account's Files "hidden files" filter: `hide`, `only` or `include`. + final String hiddenFilter; + + /// The account's Files storage scope: `cloud`, `external` or `all`. + final String storageScope; + + const ShareAccountEntry({ + required this.id, + required this.serverUrl, + required this.username, + required this.password, + required this.hiddenFilter, + required this.storageScope, + }); +} + +/// Tells the native side which accounts can upload, which one is active, and +/// the app-lock settings, so iOS's Share Extension - a separate process with +/// no Flutter engine - can offer an account picker, list folders and upload +/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s +/// `share_account`, stored in a Keychain group shared with the extension). +/// Android has no equivalent (its share intent opens the app itself), so there +/// the channel simply doesn't exist and everything here is a no-op. +class ShareAccountService { + static const _channel = MethodChannel('dev.ayushya.noo/share_account'); + + static String basicAuth(String username, String password) => + 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + + /// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes. + static String buildPayload({ + required List accounts, + required String? activeId, + required bool loginLockEnabled, + required bool lockAccountSwitching, + required bool lockHiddenFiles, + }) { + return jsonEncode({ + 'accounts': [ + for (final a in accounts) + { + 'id': a.id, + 'serverUrl': a.serverUrl, + 'username': a.username, + 'authHeader': basicAuth(a.username, a.password), + 'displayName': + '${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}', + 'hiddenFilter': a.hiddenFilter, + 'storageScope': a.storageScope, + }, + ], + 'activeId': activeId, + 'loginLockEnabled': loginLockEnabled, + 'lockAccountSwitching': lockAccountSwitching, + 'lockHiddenFiles': lockHiddenFiles, + }); + } + + /// Publishes every saved account that can actually upload (has a stored + /// password and isn't signed out), with the active one's hidden-files + /// filter and storage scope taken live from [files] - the saved prefs are + /// written asynchronously, so they can lag a change that just happened. + static Future publish( + SessionController session, + FilesController files, + ) async { + final activeId = session.activeAccountId; + if (activeId == null) return clear(); + + final prefs = await session.prefsFuture; + final store = session.accountStore; + final signedOut = store.loadSignedOut(prefs); + final entries = []; + for (final account in session.accounts) { + if (signedOut.contains(account.id)) { + debugPrint('[ShareAccounts] skipping ${account.id}: signed out'); + continue; + } + final password = await store.readPassword(account.id); + if (password == null) { + debugPrint( + '[ShareAccounts] skipping ${account.id}: no stored password', + ); + continue; + } + final isActive = account.id == activeId; + final filter = isActive + ? files.hiddenFilter + : FilesController.savedHiddenFilter( + prefs, + store.accountPrefKey, + account.id, + ); + final scope = isActive + ? files.storageScope + : FilesController.savedStorageScope( + prefs, + store.accountPrefKey, + account.id, + ); + entries.add( + ShareAccountEntry( + id: account.id, + serverUrl: account.serverUrl, + username: account.username, + password: password, + hiddenFilter: filter.name, + storageScope: scope.name, + ), + ); + } + debugPrint( + '[ShareAccounts] publishing ${entries.length} of ' + '${session.accounts.length} saved accounts, active=$activeId ' + '(${signedOut.length} signed out)', + ); + if (entries.isEmpty) return clear(); + + await invokeIfAvailable(_channel, 'setAccounts', { + 'accounts': buildPayload( + accounts: entries, + activeId: activeId, + loginLockEnabled: session.loginLockEnabled, + lockAccountSwitching: session.lockAccountSwitching, + lockHiddenFiles: session.lockHiddenFiles, + ), + }); + } + + /// Forgets everything (sign-out) so the extension stops offering uploads. + static Future clear() => invokeIfAvailable(_channel, 'clearAccount'); +} + +/// Keeps [ShareAccountService]'s published data current while the app is +/// running: republishes when an account becomes ready or when anything the +/// extension depends on changes - the account list or active account, the +/// lock settings, or the active account's hidden-files filter. +class ShareAccountSync { + final SessionController session; + final FilesController files; + + String? _lastSignature; + bool _disposed = false; + Future _queue = Future.value(); + + ShareAccountSync(this.session, this.files); + + void start() { + session.addAccountReadyListener(_publish); + session.addListener(_onChange); + files.addListener(_onChange); + } + + void dispose() { + _disposed = true; + session.removeListener(_onChange); + files.removeListener(_onChange); + } + + String _signature() => [ + session.activeAccountId, + session.accounts.map((a) => a.id).join(','), + session.loginLockEnabled, + session.lockAccountSwitching, + session.lockHiddenFiles, + files.hiddenFilter.name, + files.storageScope.name, + ].join('|'); + + void _onChange() { + if (_signature() != _lastSignature) _publish(); + } + + /// Runs one at a time, in order, so a slow publish can't land after a + /// newer one and leave the extension with stale data. + void _publish() { + if (_disposed) return; + _lastSignature = _signature(); + _queue = _queue.then((_) async { + if (_disposed) return; + try { + await ShareAccountService.publish(session, files); + } catch (_) { + // Best effort: the extension just keeps the previous data. + } + }); + } +} diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 85ec749..272d648 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'native_channel.dart'; /// A file shared to Noo from another app's "Share to..." sheet, before its /// bytes have been touched - just the `content://` Uri and whatever cheap @@ -40,7 +41,8 @@ class ShareIntentService { /// Whatever was shared to launch the app cold (empty if it was launched /// normally, not via a share). static Future> getInitialShare() async { - final result = await _methodChannel.invokeMethod>( + final result = await invokeIfAvailable>( + _methodChannel, 'getInitialShare', ); return (result ?? []) @@ -51,7 +53,7 @@ class ShareIntentService { /// Emits whenever another share arrives while the app is already running. static Stream> get onNewShare { - return _newShareChannel.receiveBroadcastStream().map((event) { + return quietEvents(_newShareChannel, (event) { return (event as List) .cast>() .map(SharedFileRef.fromMap) diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index aa870be..8c334b6 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -10,6 +10,7 @@ import '../models/saved_account.dart'; import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; import '../providers/sync_status_controller.dart'; +import 'native_channel.dart'; /// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the /// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`. @@ -104,13 +105,18 @@ class SyncService { ) : _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id); final password = await store.readPassword(account.id); - if (password == null || - config.paths.isEmpty || - config.intervalMinutes == null) { + if (password == null || config.paths.isEmpty) { await cancelAccount(account.id); continue; } - await _channel.invokeMethod('reschedule', { + if (config.intervalMinutes == null) { + // Background sync is off, but this account still has paths to sync + // by hand: keep its credentials (native uses them for "Sync now" + // and conflict notification actions). + await cancelAccount(account.id, forget: false); + continue; + } + await invokeIfAvailable(_channel, 'reschedule', { 'accountId': account.id, 'serverUrl': account.serverUrl, 'username': account.username, @@ -131,8 +137,17 @@ class SyncService { /// Stops [accountId]'s periodic job (the account was removed, or has /// nothing left to sync). - static Future cancelAccount(String accountId) async { - await _channel.invokeMethod('cancel', {'accountId': accountId}); + /// + /// [forget] (the default) is for an account that was signed out or removed: + /// native also drops its stored credentials. Pass false when only the + /// background job is being turned off - the account's paths are still + /// synced by hand ("Sync now"), and a conflict notification's actions + /// still need its credentials. + static Future cancelAccount(String accountId, {bool forget = true}) async { + await invokeIfAvailable(_channel, 'cancel', { + 'accountId': accountId, + 'forget': forget, + }); } /// Runs a one-off sync pass immediately, independent of the periodic @@ -158,7 +173,7 @@ class SyncService { } final paths = sync.syncEverything ? ['/'] : sync.syncedPaths; - await _channel.invokeMethod('syncNow', { + await invokeOrExplain(_channel, 'syncNow', 'Syncing', { 'accountId': accountId, 'serverUrl': session.serverUrl, 'username': session.username, @@ -178,7 +193,8 @@ class SyncService { /// app start - the native bus only knows an account once a sync pass has /// run in this process. static Future getStatus(String? accountId) async { - final result = await _channel.invokeMethod>( + final result = await invokeIfAvailable>( + _channel, 'getSyncStatus', {'accountId': accountId}, ); @@ -196,11 +212,10 @@ class SyncService { /// (`SyncStatusController`), so sync badges stopped updating until the app /// was restarted. This is a broadcast stream, so any number of Dart /// listeners share the one native subscription. - static final Stream statusStream = _statusChannel - .receiveBroadcastStream() - .map( - (event) => SyncStatusSnapshot.fromMap(event as Map), - ); + static final Stream statusStream = quietEvents( + _statusChannel, + (event) => SyncStatusSnapshot.fromMap(event as Map), + ); /// In-app conflict resolution (the header's "Keep local"/"Use server" /// buttons) - shares the exact same native `ConflictResolveWorker` @@ -213,7 +228,7 @@ class SyncService { ) async { final authHeader = session.service?.authHeaders['Authorization']; if (authHeader == null) throw Exception('Not logged in.'); - await _channel.invokeMethod('resolveConflict', { + await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', { 'accountId': conflict.accountId, 'serverUrl': session.serverUrl, 'username': session.username, @@ -236,12 +251,23 @@ class SyncService { ) async { final accountId = session.activeAccountId; if (accountId == null) return; - await _channel.invokeMethod('removeLocalSync', { + await invokeIfAvailable(_channel, 'removeLocalSync', { 'accountId': accountId, 'path': path, }); } + /// Where the `sync//...` 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 baseDirectory() async { + if (defaultTargetPlatform == TargetPlatform.android) { + return getExternalStorageDirectory(); + } + return getApplicationSupportDirectory(); + } + /// The deterministic local mirror path for [remoteItemPath] under /// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s /// `/sync//...` layout exactly, so this @@ -251,7 +277,7 @@ class SyncService { String accountId, String remoteItemPath, ) async { - final base = await getExternalStorageDirectory(); + final base = await baseDirectory(); if (base == null) return null; final relPath = remoteItemPath.startsWith('/') ? remoteItemPath.substring(1) diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index ebda2c2..9e6c434 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -56,7 +56,7 @@ class UploadService { .toList(), ); - await _channel.invokeMethod('startUpload', { + await invokeOrExplain(_channel, 'startUpload', 'Uploading', { ...args, 'files': filesJson, 'remoteFolder': files.currentFolderPath, @@ -70,7 +70,8 @@ class UploadService { /// deliberately: see `SyncService.statusStream`'s identical doc comment - /// `EventChannel.receiveBroadcastStream()` opens its own native /// subscription per call, and the native side only keeps the latest one. - static final Stream completions = _statusChannel - .receiveBroadcastStream() - .map((event) => UploadCompletion.fromMap(event as Map)); + static final Stream completions = quietEvents( + _statusChannel, + (event) => UploadCompletion.fromMap(event as Map), + ); } diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 4f2fbbe..dc27a09 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -139,11 +139,20 @@ class NooColors extends ThemeExtension { /// paints it, not painted directly - using it as-is for a sheet/dialog's /// `barrierColor` hid the screen behind completely instead of dimming it, /// so this uses the fixed palette's already-translucent scrim instead. + /// + /// `surface` (cards, bars, rows) has to read as lighter than `bg`, like + /// the fixed palettes' white-on-beige / lifted-on-near-black. In light + /// mode `surfaceContainerLow` is a hair *darker* than the OS-provided + /// `surface` that [bg] uses, so cards (the selection bar, list rows) + /// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark + /// mode uses `surfaceContainer` rather than `...Low` for a bigger step + /// off the page. factory NooColors.fromDynamicScheme(ColorScheme cs) { - final fixed = cs.brightness == Brightness.dark ? dark : light; + final dark = cs.brightness == Brightness.dark; + final fixed = dark ? NooColors.dark : NooColors.light; return NooColors( bg: cs.surface, - surface: cs.surfaceContainerLow, + surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest, surface2: cs.surfaceContainerHigh, surface3: cs.surfaceContainerHighest, line: cs.outlineVariant, @@ -357,8 +366,8 @@ class NooMotion { /// The one shadow the design system allows, from `tokens/spacing.css`'s /// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s -/// bar/search satellite (the only other thing in the app that's genuinely -/// floating above other content). Nothing else should use a `BoxShadow`. +/// bar/search satellite, and the floating selection action bar. +/// Nothing else should use a `BoxShadow`. const nooDialogShadow = BoxShadow( color: Color(0x241E002F), // rgba(30,0,47,.14) blurRadius: 48, diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 3cb6c4d..4ed8e9b 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/selection_bar_overlay.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; @@ -54,8 +55,14 @@ class FavoritesView extends StatefulWidget { /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; + final ValueChanged? onSelectionChanged; - const FavoritesView({super.key, required this.scrollController, this.topBar}); + const FavoritesView({ + super.key, + required this.scrollController, + this.topBar, + this.onSelectionChanged, + }); @override State createState() => _FavoritesViewState(); @@ -72,10 +79,12 @@ class _FavoritesViewState extends State { setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + widget.onSelectionChanged?.call(_isSelecting); } void _clearSelection() { setState(_selectedIds.clear); + widget.onSelectionChanged?.call(false); } /// A favorited folder switches to the Files tab, navigated there; a @@ -483,6 +492,10 @@ class _FavoritesViewState extends State { @override Widget build(BuildContext context) { + final selectionAtBottom = + _isSelecting && + context.watch().selectionBarPosition == + SelectionBarPosition.bottom; final colors = context.nooColors; final isDesktop = NooLayout.isDesktop(context); final favoritesController = context.watch(); @@ -513,15 +526,17 @@ class _FavoritesViewState extends State { final contentSlivers = [ if (widget.topBar != null) topBarSliver(widget.topBar!), - SliverPersistentHeader( - pinned: !_isSelecting, - delegate: StickyHeaderDelegate( - height: _isSelecting ? 56 : 64, - child: _isSelecting - ? _buildSelectionBar(context, selectedItems) - : controlsRow, + if (!selectionAtBottom) + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + floating: _isSelecting, + height: _isSelecting ? 56 : 64, + child: _isSelecting + ? _buildSelectionBar(context, selectedItems) + : controlsRow, + ), ), - ), if (favoritesController.isLoading && favorites.isEmpty) const SliverFillRemaining( hasScrollBody: false, @@ -646,7 +661,13 @@ class _FavoritesViewState extends State { }, childCount: favorites.length), ), ), - SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), + SliverToBoxAdapter( + child: SizedBox( + height: + bottomBarClearance(context) + + (selectionAtBottom ? SelectionBarOverlay.clearance : 0), + ), + ), ]; return PopScope( @@ -654,24 +675,29 @@ class _FavoritesViewState extends State { onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelecting) _clearSelection(); }, - child: ColoredBox( - color: colors.bg, - child: RefreshIndicator( - color: colors.accent, - backgroundColor: colors.surface, - onRefresh: favoritesController.fetchAll, - // See files_view.dart's identical fix - without this, the sticky - // controls row rides up under the status bar once the floating - // top bar above it fully collapses. - child: SafeArea( - top: true, - bottom: false, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + child: SelectionBarOverlay( + bar: selectionAtBottom + ? _buildSelectionBar(context, selectedItems) + : null, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: favoritesController.fetchAll, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating + // top bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 32e22f0..5857e51 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -178,6 +178,10 @@ class _FileViewerScreenState extends State { final _panelKey = GlobalKey(); + /// Edit/Preview + Save for a text/markdown file: the preview owns the + /// editing, this just lets the top bar draw (and trigger) its buttons. + final _textActions = TextPreviewController(); + void _openDetails() { if (NooLayout.isDesktop(context)) { DetailsSheet.show(context, _currentItem); @@ -229,6 +233,7 @@ class _FileViewerScreenState extends State { @override void dispose() { + _textActions.dispose(); _pageController.dispose(); super.dispose(); } @@ -423,9 +428,7 @@ class _FileViewerScreenState extends State { // The stage follows the app's own theme (`bg`) rather than being pinned // to black - a black stage in light mode read as jarringly out of place // next to the rest of the light-themed app. The floating top/action - // bars now follow the theme too (FrostedGlassContainer's own `surface` - // default), at a higher blur opacity to stay legible over arbitrary - // photo/video brightness underneath. + // bars follow the theme and share the navigation frost preference. final stageColor = colors.bg; return Scaffold( @@ -532,7 +535,14 @@ class _FileViewerScreenState extends State { ), ), ), - const SizedBox(width: 10), + // Edit/Preview and Save for text/markdown files - + // empty (zero-width) for every other file type. + ListenableBuilder( + listenable: _textActions, + builder: (context, _) => + _TextActions(controller: _textActions), + ), + const SizedBox(width: 4), ], ), ), @@ -731,6 +741,7 @@ class _FileViewerScreenState extends State { return MediaTextPreview( item: widget.item, session: session, + controller: _textActions, localPathResolver: widget.localPathResolver, ); } @@ -743,3 +754,103 @@ class _FileViewerScreenState extends State { } } } + +/// The text viewer's top-bar buttons: Edit/Preview (markdown only) and Save +/// (once the content changed; a spinner while it saves). Same +/// round [NooButton] look (primary for the main action, secondary beside it). +class _TextActions extends StatelessWidget { + final TextPreviewController controller; + + const _TextActions({required this.controller}); + + @override + Widget build(BuildContext context) { + final toggle = controller.canToggleEditing; + final save = controller.saving || controller.showSave; + // One button is the primary action; with two, Save is and Edit/Preview + // steps back to secondary. + final both = toggle && save; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (toggle) + _ActionButton( + icon: controller.editing ? LucideIcons.eye : LucideIcons.pencil, + tooltip: controller.editing ? 'Preview' : 'Edit', + variant: both + ? NooButtonVariant.secondary + : NooButtonVariant.primary, + onTap: controller.toggleEditing, + ), + if (both) const SizedBox(width: 8), + if (controller.saving) + const _SavingIndicator() + else if (save) + _ActionButton( + icon: LucideIcons.save, + tooltip: 'Save', + variant: NooButtonVariant.primary, + onTap: controller.save, + ), + ], + ); + } +} + +/// A round icon-only [NooButton] with the tooltip and semantic label an +/// icon-only button needs. +class _ActionButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final NooButtonVariant variant; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.tooltip, + required this.variant, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Semantics( + button: true, + label: tooltip, + excludeSemantics: true, + child: NooButton( + icon: icon, + iconOnly: true, + variant: variant, + onTap: onTap, + ), + ), + ); + } +} + +/// Stands in for the Save button while it saves: the same primary circle, +/// with a spinner, so it can't be tapped twice. +class _SavingIndicator extends StatelessWidget { + const _SavingIndicator(); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + label: 'Saving', + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.accent, shape: BoxShape.circle), + child: const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index f834097..487b53f 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/selection_bar_overlay.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; @@ -64,18 +65,71 @@ class FilesView extends StatefulWidget { /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; + final ValueChanged? onSelectionChanged; const FilesView({ super.key, required this.scrollController, this.offline = false, this.topBar, + this.onSelectionChanged, }); @override State createState() => _FilesViewState(); } +/// The collapsible "External storage" header [StorageScope.all] puts above +/// the external items. +class _ExternalStorageHeader extends StatelessWidget { + final int count; + final bool expanded; + final VoidCallback onTap; + + const _ExternalStorageHeader({ + required this.count, + required this.expanded, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + button: true, + expanded: expanded, + label: 'External storage', + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 14, + ), + child: Row( + children: [ + Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2), + const SizedBox(width: 10), + Text( + 'External storage', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + const SizedBox(width: 8), + Text('$count', style: NooText.body.copyWith(color: colors.fg3)), + const Spacer(), + Icon( + expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown, + size: 18, + color: colors.fg3, + ), + ], + ), + ), + ), + ); + } +} + /// Slides+fades its child in on first build. Give it a [Key] that changes /// whenever the folder changes (folder path + item id) so Flutter discards /// and remounts the Element instead of just updating it in place - that's @@ -236,6 +290,9 @@ class _GridThumbnail extends StatelessWidget { class _FilesViewState extends State { final Set _selectedIds = {}; + + /// Whether [StorageScope.all]'s external-storage section is open. + bool _externalExpanded = true; int _lastPathDepth = 1; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -245,9 +302,19 @@ class _FilesViewState extends State { /// The folder data source for this tab. Watches the concrete controller /// (that's what's registered as a provider), then hands it back as the /// [FolderBrowser] the rest of the view actually needs. - FolderBrowser _browserOf(BuildContext context) => _offline - ? context.watch() - : context.watch(); + /// + /// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside + /// of `build` asserts in debug builds (silently swallowing the tap). + FolderBrowser _browserOf(BuildContext context, {bool listen = true}) { + if (listen) { + return _offline + ? context.watch() + : context.watch(); + } + return _offline + ? context.read() + : context.read(); + } /// The on-device copy of [item] for the Offline tab; null online, where /// thumbnails come from server previews instead. @@ -273,10 +340,12 @@ class _FilesViewState extends State { setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + widget.onSelectionChanged?.call(_isSelecting); } void _clearSelection() { setState(_selectedIds.clear); + widget.onSelectionChanged?.call(false); } /// Routes a tap on an item, in picking, selecting or plain-browsing mode @@ -287,7 +356,7 @@ class _FilesViewState extends State { NextcloudItem item, { required bool picking, }) { - final browser = _browserOf(context); + final browser = _browserOf(context, listen: false); if (picking) { _handlePickTap(context, item); } else if (_isSelecting) { @@ -560,34 +629,31 @@ class _FilesViewState extends State { ); } - /// The user's configured swipe action for one side of a row, or null if - /// that side is off or set to an action the design's two-slot - /// `NooSwipeAction` has no room for (`SwipeAction.share` - still reachable - /// via the row's overflow menu / the "Share" bulk action). - NooSwipeActionSpec? _swipeSpec( - SwipeAction action, - NextcloudItem item, - ItemOperations ops, - ) { - switch (action) { - case SwipeAction.favorite: - return NooSwipeActionSpec( - kind: NooSwipeActionKind.favorite, - onTriggered: () => ops.toggleItemFavorite(item), - ); - case SwipeAction.delete: - return NooSwipeActionSpec( - kind: NooSwipeActionKind.delete, - onTriggered: () => _confirmAndDeleteViaSwipe(item), - ); - case SwipeAction.share: - case SwipeAction.none: - return null; - } + /// Reuse the single-item action handlers so swipe gestures and the action + /// bar agree on labels, sheets and mutations. Delete retains its swipe + /// confirmation before making any server changes. + NooSwipeActionSpec? _swipeSpec(SwipeAction action, NextcloudItem item) { + final kind = action.selectionKind; + if (kind == null) return null; + final selectedAction = _buildSelectionActions(context, [ + item, + ]).firstWhere((candidate) => candidate.kind == kind); + return NooSwipeActionSpec( + kind: kind, + label: selectedAction.label, + icon: selectedAction.icon, + onTriggered: kind == SelectionActionKind.delete + ? () => _confirmAndDeleteViaSwipe(item) + : selectedAction.onTap, + ); } @override Widget build(BuildContext context) { + final selectionAtBottom = + _isSelecting && + context.watch().selectionBarPosition == + SelectionBarPosition.bottom; final colors = context.nooColors; // Display prefs (grid/list, filters, sort) live on FilesController for // both tabs; `browser` is the tab's own folder listing. @@ -659,23 +725,149 @@ class _FilesViewState extends State { ), ); + // 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 itemSlivers( + List 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 []; + final bodySlivers = [ + 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 contentSlivers = [ if (widget.topBar != null) topBarSliver(widget.topBar!), // Pinned in both states - while browsing this is the controls row // (+ breadcrumbs), while selecting it's the selection bar (see // `topRow` above): either way it's the one thing that always stays // at the very top of the scroll view. - SliverPersistentHeader( - pinned: true, - delegate: StickyHeaderDelegate( - // 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's - // fixed height) [+ 10 gap + 32 breadcrumbs height, if present]. - // Getting this wrong overflows the sliver header by exactly the - // shortfall - a real bug this shipped with once already. - height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64), - child: topRow, + if (!selectionAtBottom) + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + floating: _isSelecting, + // 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's + // fixed height) [+ 10 gap + 32 breadcrumbs height, if present]. + // Getting this wrong overflows the sliver header by exactly the + // shortfall - a real bug this shipped with once already. + height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64), + child: topRow, + ), ), - ), if (_offline) SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)), // Files List / Grid @@ -743,96 +935,24 @@ class _FilesViewState extends State { ), ), ) - else if (files.isGridView) - SliverPadding( - key: const ValueKey('files-grid'), - padding: EdgeInsets.fromLTRB( - gutter, - NooSpace.xs, - gutter, - NooSpace.lg, - ), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: isDesktop - ? 5 - : NooLayout.gridColumns(context, phone: 2, minTile: 180), - childAspectRatio: isDesktop ? 1.05 : 0.92, - crossAxisSpacing: isDesktop ? 16 : 10, - mainAxisSpacing: isDesktop ? 16 : 10, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = browser.items[index]; - return _FolderEnterAnimation( - key: ValueKey('${browser.currentFolderPath}::${item.id}'), - index: index, - fromRight: navigatingDeeper, - child: _buildGridCard(context, item), - ); - }, childCount: browser.items.length), - ), - ) - else if (isDesktop) ...[ - SliverPadding( - key: const ValueKey('files-table-header'), - padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), - sliver: SliverToBoxAdapter( - child: _buildDesktopHeader( - context, - files, - browser.currentFolderPath, - ), - ), - ), - SliverPadding( - key: const ValueKey('files-table'), - padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = browser.items[index]; - return _FolderEnterAnimation( - key: ValueKey('${browser.currentFolderPath}::${item.id}'), - index: index, - fromRight: navigatingDeeper, - child: _buildDesktopRow(context, item), - ); - }, childCount: browser.items.length), - ), - ), - ] else - SliverPadding( - key: const ValueKey('files-list'), - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.xs, - NooSpace.sm, - NooSpace.lg, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = browser.items[index]; - return _FolderEnterAnimation( - key: ValueKey('${browser.currentFolderPath}::${item.id}'), - index: index, - fromRight: navigatingDeeper, - child: _buildMobileRow( - context, - item, - index, - browser.items.length, - ), - ); - }, childCount: browser.items.length), - ), - ), + else + ...bodySlivers, // List/grid/table above only pad NooSpace.lg at the bottom - plenty // once Scaffold shrinks the body above an attached bar, but floating - // draws the body behind the bar instead, so it needs the bar's own - // footprint added on top or the last row ends up under it. - if (context.watch().bottomBarStyle == - NooBottomBarStyle.floating) + // draws the body behind the bar instead (as does a frosted bar), so it + // needs the bar's own footprint added on top or the last row ends up + // under it. + if (selectionAtBottom || + NooBottomBar.drawsBehindBody( + context.watch().bottomBarStyle, + context.watch().bottomBarFrosted, + )) SliverToBoxAdapter( - child: SizedBox(height: bottomBarClearance(context)), + child: SizedBox( + height: + bottomBarClearance(context) + + (selectionAtBottom ? SelectionBarOverlay.clearance : 0), + ), ), ]; @@ -846,38 +966,43 @@ class _FilesViewState extends State { browser.navigateUp(); } }, - child: ColoredBox( - color: colors.bg, - child: RefreshIndicator( - color: colors.accent, - backgroundColor: colors.surface, - onRefresh: () { - unawaited(sync.syncOnPull()); - return browser.reload(); - }, - // `topBarSliver`'s floating header can collapse all the way to - // zero height (fully scrolled away), at which point the sticky - // controls row right below it in `contentSlivers` would otherwise - // ride up underneath the status bar instead of stopping below it - // - the floating top bar used to be the only thing reserving that - // space (via its own internal `SafeArea`), and that reservation - // disappears along with it once it's fully hidden. Wrapping the - // whole scroll view keeps the inset outside the scrolling region - // entirely, so it's never implicated in the floating header's own - // collapse/reveal math - safe to apply unconditionally, since - // desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no - // status bar there). - child: SafeArea( - top: true, - bottom: false, - child: CustomScrollView( - controller: widget.scrollController, - // Pull-to-refresh needs a scroll physics that allows dragging - // past the edge even when content doesn't fill the viewport - - // an empty or single-item list otherwise can't be pulled at - // all under the platform default physics. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + child: SelectionBarOverlay( + bar: selectionAtBottom + ? _buildSelectionBar(context, selectedItems) + : null, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: () { + unawaited(sync.syncOnPull()); + return browser.reload(); + }, + // `topBarSliver`'s floating header can collapse all the way to + // zero height (fully scrolled away), at which point the sticky + // controls row right below it in `contentSlivers` would otherwise + // ride up underneath the status bar instead of stopping below it + // - the floating top bar used to be the only thing reserving that + // space (via its own internal `SafeArea`), and that reservation + // disappears along with it once it's fully hidden. Wrapping the + // whole scroll view keeps the inset outside the scrolling region + // entirely, so it's never implicated in the floating header's own + // collapse/reveal math - safe to apply unconditionally, since + // desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no + // status bar there). + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // Pull-to-refresh needs a scroll physics that allows dragging + // past the edge even when content doesn't fill the viewport - + // an empty or single-item list otherwise can't be pulled at + // all under the platform default physics. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), @@ -1211,7 +1336,6 @@ class _FilesViewState extends State { final sync = context.watch(); final session = context.watch(); final settings = context.watch(); - final ops = context.read(); final isSelected = _selectedIds.contains(item.id); // Pick mode and selection are server-side features - never on Offline. final picking = !_offline && pick.isPicking; @@ -1249,8 +1373,8 @@ class _FilesViewState extends State { final swipeable = _isSelecting || picking || _offline ? row : NooSwipeAction( - startAction: _swipeSpec(settings.swipeRightAction, item, ops), - endAction: _swipeSpec(settings.swipeLeftAction, item, ops), + startAction: _swipeSpec(settings.swipeRightAction, item), + endAction: _swipeSpec(settings.swipeLeftAction, item), child: row, ); diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 51bd79b..39ac518 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -15,10 +15,10 @@ import '../providers/settings_controller.dart'; import '../services/download_service.dart'; import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; +import '../widgets/filter_mode_rows.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_chip.dart'; import '../widgets/noo/core/noo_segmented_control.dart'; -import '../widgets/noo/core/noo_toggle.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/lists/noo_selection_bar.dart'; @@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/selection_bar_overlay.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -57,8 +58,14 @@ class PhotosView extends StatefulWidget { /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; + final ValueChanged? onSelectionChanged; - const PhotosView({super.key, required this.scrollController, this.topBar}); + const PhotosView({ + super.key, + required this.scrollController, + this.topBar, + this.onSelectionChanged, + }); @override State createState() => _PhotosViewState(); @@ -81,10 +88,12 @@ class _PhotosViewState extends State { setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + widget.onSelectionChanged?.call(_isSelecting); } void _clearSelection() { setState(_selectedIds.clear); + widget.onSelectionChanged?.call(false); } /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is @@ -214,6 +223,10 @@ class _PhotosViewState extends State { @override Widget build(BuildContext context) { + final selectionAtBottom = + _isSelecting && + context.watch().selectionBarPosition == + SelectionBarPosition.bottom; final colors = context.nooColors; final isDesktop = NooLayout.isDesktop(context); final photosController = context.watch(); @@ -247,23 +260,25 @@ class _PhotosViewState extends State { if (widget.topBar != null) topBarSliver(widget.topBar!), // Sticky while browsing; once selecting, the selection bar takes over // the same slot instead. - SliverPersistentHeader( - pinned: !_isSelecting, - delegate: StickyHeaderDelegate( - height: _isSelecting ? 56 : 64, - child: _isSelecting - ? _buildSelectionBar(context, pick, selectedItems) - : Padding( - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.sm, - NooSpace.sm, - NooSpace.xs, + if (!selectionAtBottom) + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + floating: _isSelecting, + height: _isSelecting ? 56 : 64, + child: _isSelecting + ? _buildSelectionBar(context, pick, selectedItems) + : Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xs, + ), + child: controlsRow, ), - child: controlsRow, - ), + ), ), - ), if (photosController.isLoading && allPhotos.isEmpty) const SliverFillRemaining( @@ -359,7 +374,13 @@ class _PhotosViewState extends State { // Clearance so the last row isn't hidden behind the nav bar, // regardless of grid length - see `bottomBarClearance`'s own doc // comment for why this has to be dynamic rather than a flat 100. - SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), + SliverToBoxAdapter( + child: SizedBox( + height: + bottomBarClearance(context) + + (selectionAtBottom ? SelectionBarOverlay.clearance : 0), + ), + ), ]; return PopScope( @@ -367,24 +388,29 @@ class _PhotosViewState extends State { onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelecting) _clearSelection(); }, - child: ColoredBox( - color: colors.bg, - child: RefreshIndicator( - color: colors.accent, - backgroundColor: colors.surface, - onRefresh: photosController.fetchAllMedia, - // See files_view.dart's identical fix - without this, the sticky - // controls row rides up under the status bar once the floating - // top bar above it fully collapses. - child: SafeArea( - top: true, - bottom: false, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + child: SelectionBarOverlay( + bar: selectionAtBottom + ? _buildSelectionBar(context, pick, selectedItems) + : null, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: photosController.fetchAllMedia, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating + // top bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), @@ -404,7 +430,7 @@ class _PhotosViewState extends State { final filtersActive = _typeFilter != _PhotoTypeFilter.all || photos.showFavoritesOnly || - photos.showHidden || + photos.hiddenFilter != HiddenFilesFilter.hide || files.storageScope != StorageScope.cloud; // A plain Row, not a horizontally-scrolling one - see @@ -521,63 +547,74 @@ class _PhotosViewState extends State { // Same "icon always, label only when selected" pill as // Files' own type filter - not a fully-labelled track, so // the two screens' filter sheets look and behave the same. - NooSegmentedControl<_PhotoTypeFilter>( - fill: true, - onSurface: true, - labelOnlySelected: true, - value: _typeFilter, - onChanged: (filter) => setState(() { - _typeFilter = filter; - setSheetState(() {}); - }), - options: const [ - NooSegmentOption( - value: _PhotoTypeFilter.all, - icon: LucideIcons.layoutGrid, - label: 'All', - ), - NooSegmentOption( - value: _PhotoTypeFilter.image, - icon: LucideIcons.image, - label: 'Photos', - ), - NooSegmentOption( - value: _PhotoTypeFilter.video, - icon: LucideIcons.film, - label: 'Videos', - ), - ], + FilterSection( + icon: LucideIcons.layoutGrid, + title: 'Type', + child: NooSegmentedControl<_PhotoTypeFilter>( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: _typeFilter, + onChanged: (filter) => setState(() { + _typeFilter = filter; + setSheetState(() {}); + }), + options: const [ + NooSegmentOption( + value: _PhotoTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', + ), + NooSegmentOption( + value: _PhotoTypeFilter.image, + icon: LucideIcons.image, + label: 'Photos', + ), + NooSegmentOption( + value: _PhotoTypeFilter.video, + icon: LucideIcons.film, + label: 'Videos', + ), + ], + ), ), const SizedBox(height: 22), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), + FilterSection( + icon: LucideIcons.heart, + title: 'Favorites', + child: NooSegmentedControl( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: photos.showFavoritesOnly, + onChanged: (value) { + if (value != photos.showFavoritesOnly) { + photos.toggleFavoritesFilter(); + } + }, + options: const [ + NooSegmentOption( + value: false, + icon: LucideIcons.layers, + label: 'All', ), - ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), + NooSegmentOption( + value: true, + icon: LucideIcons.heart, + label: 'Favorites only', ), - ), - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, - ), - ), - ), - ], + ], + ), + ), + const SizedBox(height: 22), + HiddenFilesFilterRow( + value: photos.hiddenFilter, + onChanged: photos.setHiddenFilter, + ), + const SizedBox(height: 22), + StorageScopeRow( + value: files.storageScope, + onChanged: files.setStorageScope, ), ], ), diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart index 9be3456..c9ff2d5 100644 --- a/lib/widgets/app_tab_view_builder.dart +++ b/lib/widgets/app_tab_view_builder.dart @@ -24,6 +24,7 @@ Widget buildAppTabView( AppTab tab, ScrollController controller, { PreferredSizeWidget? topBar, + ValueChanged? onSelectionChanged, }) { switch (tab) { case AppTab.files: @@ -33,11 +34,20 @@ Widget buildAppTabView( key: const ValueKey('files'), scrollController: controller, topBar: topBar, + onSelectionChanged: onSelectionChanged, ); case AppTab.photos: - return PhotosView(scrollController: controller, topBar: topBar); + return PhotosView( + scrollController: controller, + topBar: topBar, + onSelectionChanged: onSelectionChanged, + ); case AppTab.favorites: - return FavoritesView(scrollController: controller, topBar: topBar); + return FavoritesView( + scrollController: controller, + topBar: topBar, + onSelectionChanged: onSelectionChanged, + ); case AppTab.activity: return ActivityView(scrollController: controller, topBar: topBar); case AppTab.trash: @@ -52,6 +62,7 @@ Widget buildAppTabView( scrollController: controller, offline: true, topBar: topBar, + onSelectionChanged: onSelectionChanged, ); } } diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index 8fa27a4..7cc293c 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; +import '../providers/settings_controller.dart'; import 'avatar_menu.dart'; import 'create_menu.dart'; import 'noo/nav/noo_top_bar.dart'; +import 'noo/core/noo_button.dart'; +import '../theme/design_tokens.dart'; import 'shell/shell_common.dart'; /// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular @@ -11,9 +14,9 @@ import 'shell/shell_common.dart'; /// tabs built their own via `SyncedHeaderScaffold`, which the rework drops). /// Built on [NooTopBar]: /// -/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a -/// create/upload flow) and the account avatar trailing, then the large -/// title, then an inline search field that opens [SearchView]. +/// - iOS: large title, Upload button and account avatar in one row, then +/// an inline search field that opens [SearchView]. Upload shares Android's +/// Auto/Collapsed/Expanded preference. /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon /// here - it's the [NooFab] `MainShellView` shows instead. When search /// isn't in the bottom bar, an inline search-field-styled launcher @@ -37,6 +40,8 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { final AppTab tab; final bool searchInBottomBar; final NooNavMenuStyle navMenuStyle; + final AvatarPosition avatarPosition; + final FabStyle uploadButtonStyle; const AppTopBar({ super.key, @@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { required this.tab, this.searchInBottomBar = false, this.navMenuStyle = NooNavMenuStyle.drawer, + this.avatarPosition = AvatarPosition.top, + this.uploadButtonStyle = FabStyle.auto, }); bool get _ios => style == NooNavStyle.ios; bool get _showSearch => !searchInBottomBar; + bool get _uploadCollapsed => switch (uploadButtonStyle) { + FabStyle.auto => tab != AppTab.files && tab != AppTab.photos, + FabStyle.mini => true, + FabStyle.expanded => false, + }; @override Size get preferredSize => NooTopBar( style: style, title: tab.label, + iosActionsInTitleRow: true, // A throwaway placeholder purely to make preferredSize's "is there a // search slot" check match what build() actually shows - never // inserted into the tree, so its content doesn't matter. @@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { return NooTopBar( style: style, title: tab.label, + iosActionsInTitleRow: true, onMenu: navMenuStyle == NooNavMenuStyle.drawer ? () => Scaffold.of(context).openDrawer() : null, @@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { ? const ShellSearchLauncher() : null, actions: [ - if (_ios && tab == AppTab.files) - NooTopBarButton( - icon: LucideIcons.plus, - tooltip: 'Create', - onTap: () => showCreateMenu(context), + if (_ios) + Tooltip( + message: 'Upload', + child: Semantics( + button: true, + label: 'Upload', + child: Builder( + builder: (context) { + Widget button(double progress) => NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.compact, + icon: LucideIcons.plus, + expansion: progress, + onTap: () => showCreateMenu(context), + child: const Text('Upload'), + ); + final scope = context + .dependOnInheritedWidgetOfExactType< + UploadButtonAnimation + >(); + if (scope != null) return button(scope.progress); + return TweenAnimationBuilder( + tween: Tween(end: _uploadCollapsed ? 0 : 1), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : NooMotion.base, + curve: NooMotion.ease, + builder: (context, progress, _) => button(progress), + ); + }, + ), + ), + ), + if (navMenuStyle != NooNavMenuStyle.avatarMenu || + avatarPosition == AvatarPosition.top) + AvatarNavigationAnchor( + child: Builder( + builder: (anchorContext) => ShellAvatarButton( + hitBox: _ios ? 44 : 48, + onTap: navMenuStyle == NooNavMenuStyle.avatarMenu + ? () => showAvatarMenu(anchorContext) + : null, + label: navMenuStyle == NooNavMenuStyle.avatarMenu + ? 'Menu' + : 'Accounts', + ), + ), ), - ShellAvatarButton( - hitBox: _ios ? 44 : 48, - onTap: navMenuStyle == NooNavMenuStyle.avatarMenu - ? () => showAvatarMenu(context) - : null, - label: navMenuStyle == NooNavMenuStyle.avatarMenu - ? 'Menu' - : 'Accounts', - ), ], search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null, ); } } + +/// Shares one transition across the shell's separately mounted tab headers. +class UploadButtonAnimation extends InheritedWidget { + final double progress; + const UploadButtonAnimation({ + super.key, + required this.progress, + required super.child, + }); + @override + bool updateShouldNotify(UploadButtonAnimation oldWidget) => + progress != oldWidget.progress; +} diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index 3c13dcc..5778eb8 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter, lerpDouble; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; @@ -6,53 +7,85 @@ import '../providers/session_controller.dart'; import '../providers/settings_controller.dart'; import '../providers/trash_controller.dart'; import '../theme/design_tokens.dart'; -import 'noo/core/noo_avatar.dart'; import '../views/login_view.dart'; +import 'noo/core/noo_avatar.dart'; import 'noo/core/noo_badge.dart'; +import 'noo/nav/noo_bottom_bar.dart'; +import 'noo/noo_layout.dart'; import 'noo/lists/noo_settings_row.dart'; import 'shell/shell_common.dart'; -/// The dropdown [ShellAvatarButton] opens when -/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] - -/// everything [AppDrawer] holds beyond its storage bar (hidden tabs, -/// Settings), reached from the avatar instead of a hamburger-triggered -/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/ -/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming) -/// barrier and a card anchored under the top bar rather than a modal -/// sheet/dialog, and there's no existing anchored-popup primitive in this -/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full -/// content column the way this needs to). -Future showAvatarMenu(BuildContext context) { - return showGeneralDialog( +/// The shell supplies the expanding-card presentation. Standalone top bars +/// retain a dialog fallback (for screens outside the shell). +Future showAvatarMenu(BuildContext context) async { + final scope = AvatarNavigationScope.maybeOf(context); + if (scope != null) { + scope._host.openFrom(context); + return; + } + await showGeneralDialog( context: context, barrierColor: Colors.transparent, barrierDismissible: true, barrierLabel: 'Close menu', - transitionDuration: NooMotion.fast, - pageBuilder: (context, _, _) => const _AvatarMenuContent(), + transitionDuration: NooMotion.base, + pageBuilder: (context, _, _) => Align( + alignment: Alignment.topCenter, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), + child: _StandaloneAvatarCard(), + ), + ), + ), transitionBuilder: (context, animation, _, child) => FadeTransition( opacity: animation, child: ScaleTransition( alignment: Alignment.topRight, - scale: Tween( - begin: 0.96, - end: 1, - ).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)), + scale: Tween(begin: .88, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), child: child, ), ), ); } -class _AvatarMenuContent extends StatefulWidget { - const _AvatarMenuContent(); - +class _StandaloneAvatarCard extends StatefulWidget { @override - State<_AvatarMenuContent> createState() => _AvatarMenuContentState(); + State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState(); } -class _AvatarMenuContentState extends State<_AvatarMenuContent> { - bool _expanded = false; +class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> { + bool expanded = false; + @override + Widget build(BuildContext context) => AvatarMenuCard( + expanded: expanded, + onExpand: () => setState(() => expanded = !expanded), + onClose: () => Navigator.pop(context), + ); +} + +/// Account controls and hidden-tab navigation, shared by both card positions. +class AvatarMenuCard extends StatelessWidget { + final bool bottom; + final bool expanded; + final bool movingAvatar; + final bool showHeaderAvatar; + final Key? headerAvatarKey; + final VoidCallback onExpand; + final VoidCallback onClose; + const AvatarMenuCard({ + super.key, + this.bottom = false, + this.movingAvatar = false, + this.showHeaderAvatar = true, + this.headerAvatarKey, + required this.expanded, + required this.onExpand, + required this.onClose, + }); @override Widget build(BuildContext context) { @@ -60,224 +93,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> { final session = context.watch(); final settings = context.watch(); final trashCount = context.watch().items.length; - final hiddenTabs = settings.tabOrder - .where((t) => settings.hiddenTabs.contains(t)) - .toList(); - - void closeAndOpenSettings() { - Navigator.pop(context); + void settingsTap() { + onClose(); openSettings(context); } - return Align( - alignment: Alignment.topCenter, - // Just the status-bar inset, not the top bar's own height on top of - // it - the card covers the top bar (title included) rather than - // sitting below it, so opening it reads as the avatar growing into - // this instead of a separate element appearing underneath the row - // it came from. - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.md), - child: SizedBox( - width: double.infinity, - child: Container( - // No `color`/`clipBehavior` here - a `Container` with both a - // `border` and `clipBehavior` set paints the border as part - // of its *outer* decoration, then paints its (clipped) child - // on top right up to that same boundary with no gap for the - // border's own stroke width to show through. An opaque child - // touching that edge - every row below has its own full-bleed - // `Material` fill - then paints straight over the inner half - // of the border, which read as the border going missing - // specifically wherever an opaque row sits (every corner but - // the two by the header, which has no opaque fill of its - // own). The 1px `Padding` + inset `ClipRRect` below keeps the - // clipped, filled content entirely inside the border's own - // stroke instead of racing it for the same pixels. - decoration: BoxDecoration( - border: Border.all(color: colors.line), - borderRadius: BorderRadius.circular(NooRadii.card), - // `nooDialogShadow` alone is a wide, soft, fairly faint - // shadow - built for a desktop dialog with plenty of room - // to fall off into. On a small card over a dark theme's - // near-black `bg`, that falloff is too gradual to read as - // elevation at all (a dark shadow needs real density close - // to the edge to be visible against an already-dark - // backdrop). A second, tighter, more opaque contact shadow - // underneath it gives an immediate value-step right at the - // card's edge in both themes, with the soft one still - // doing the wider ambient falloff on top. - boxShadow: const [ - BoxShadow( - color: Color(0x40000000), - blurRadius: 12, - offset: Offset(0, 4), - ), - nooDialogShadow, - ], - ), - child: Padding( - padding: const EdgeInsets.all(1), - child: ClipRRect( - borderRadius: BorderRadius.circular(NooRadii.card - 1), - child: Material( - color: colors.surface, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Avatar on the right, name/host on the left - mirrors the - // top bar's own right-aligned avatar exactly, so this - // header reads as a continuation of the button that - // opened it rather than a disconnected card. - InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.all(NooSpace.md), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - session.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.bodyL.copyWith( - fontWeight: FontWeight.w600, - color: colors.fg1, - ), - ), - const SizedBox(height: 2), - Text( - serverHost(session.serverUrl), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.meta.copyWith( - color: colors.fg3, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - NooAvatar( - initials: accountInitial(session.username), - current: true, - size: 40, - ), - const SizedBox(width: 8), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colors.fg1.withValues(alpha: 0.08), - ), - child: Icon( - _expanded - ? LucideIcons.chevronUp - : LucideIcons.chevronDown, - size: 20, - color: colors.fg2, - ), - ), - ], - ), - ), - ), - AnimatedSize( - duration: NooMotion.fast, - curve: NooMotion.ease, - alignment: Alignment.topCenter, - child: _expanded - ? Column( - children: [ - for (final account in session.accounts) - if (account.id != session.activeAccountId) - _OtherAccountRow( - name: account.username, - host: serverHost(account.serverUrl), - onTap: () { - Navigator.pop(context); - session.switchAccount(account.id); - }, - ), - Padding( - padding: const EdgeInsets.fromLTRB( - NooSpace.md, - 0, - NooSpace.md, - NooSpace.md, - ), - child: Row( - spacing: 8, - children: [ - Expanded( - child: _AccountButton( - icon: LucideIcons.userPlus, - label: 'Add Account', - onTap: () { - final nav = Navigator.of( - context, - ); - nav.pop(); - nav.push( - MaterialPageRoute( - builder: (_) => - const LoginView( - isAddingAccount: true, - ), - ), - ); - }, - ), - ), - Expanded( - child: _AccountButton( - icon: LucideIcons.users, - label: 'Manage Accounts', - onTap: closeAndOpenSettings, - ), - ), - ], - ), - ), - ], - ) - : const SizedBox(width: double.infinity), - ), - if (hiddenTabs.isNotEmpty) ...[ - Divider(height: 1, color: colors.line), - for (final tab in hiddenTabs) - NooSettingsRow( - icon: tab.icon, - label: Text(tab.label), - trailing: tab == AppTab.trash && trashCount > 0 - ? NooBadge( - tone: NooBadgeTone.accent, - child: Text('$trashCount'), - ) - : null, - onTap: () { - Navigator.pop(context); - settings.requestTab(tab); - }, - ), - ], - Divider(height: 1, color: colors.line), - NooSettingsRow( - icon: LucideIcons.settings, - label: const Text('Settings'), - onTap: closeAndOpenSettings, - ), - ], + final header = InkWell( + onTap: onExpand, + child: Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w600, + color: colors.fg1, ), ), + const SizedBox(height: 2), + Text( + serverHost(session.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + Icon( + expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown, + size: 20, + color: colors.fg2, + ), + if (showHeaderAvatar) const SizedBox(width: 12), + if (showHeaderAvatar) + movingAvatar + ? SizedBox.square(key: headerAvatarKey, dimension: 32) + : GestureDetector( + onTap: onClose, + child: NooAvatar( + key: headerAvatarKey, + initials: accountInitial(session.username), + current: true, + size: 32, + ), + ), + ], + ), + ), + ); + final accountSection = AnimatedSize( + duration: NooMotion.base, + curve: Curves.easeOutCubic, + alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter, + child: expanded + ? Column( + children: [ + for (final account in session.accounts) + if (account.id != session.activeAccountId) + _OtherAccountRow( + name: account.username, + host: serverHost(account.serverUrl), + onTap: () { + onClose(); + session.switchAccount(account.id); + }, + ), + Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + spacing: 8, + children: [ + Expanded( + child: _AccountButton( + icon: LucideIcons.userPlus, + label: 'Add Account', + onTap: () { + final nav = Navigator.of(context); + onClose(); + nav.push( + MaterialPageRoute( + builder: (_) => + const LoginView(isAddingAccount: true), + ), + ); + }, + ), + ), + Expanded( + child: _AccountButton( + icon: LucideIcons.users, + label: 'Manage Accounts', + onTap: settingsTap, + ), + ), + ], + ), ), + ], + ) + : const SizedBox(width: double.infinity), + ); + final rows = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!bottom) accountSection, + for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains)) + NooSettingsRow( + backgroundColor: Colors.transparent, + icon: tab.icon, + label: Text(tab.label), + trailing: tab == AppTab.trash && trashCount > 0 + ? NooBadge( + tone: NooBadgeTone.accent, + child: Text('$trashCount'), + ) + : null, + onTap: () { + onClose(); + settings.requestTab(tab); + }, + ), + Divider(height: 1, color: colors.line), + NooSettingsRow( + backgroundColor: Colors.transparent, + icon: LucideIcons.settings, + label: const Text('Settings'), + onTap: settingsTap, + ), + if (bottom) ...[header, accountSection], + ], + ); + final content = SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(1), + child: ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card - 1), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!bottom) header, + Flexible( + fit: FlexFit.loose, + child: SingleChildScrollView(child: rows), + ), + ], + ), + ), + ), + ), + ); + return movingAvatar + ? content + : _AvatarSurface( + radius: BorderRadius.circular(NooRadii.card), + child: content, + ); + } +} + +class _AvatarSurface extends StatelessWidget { + final BorderRadius radius; + final Widget child; + final bool connected; + const _AvatarSurface({ + required this.radius, + required this.child, + this.connected = false, + }); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final colors = context.nooColors; + final frosted = settings.bottomBarFrosted; + final surface = DecoratedBox( + decoration: BoxDecoration( + color: frosted + ? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity) + : colors.surface, + border: connected ? null : Border.all(color: colors.line), + borderRadius: radius, + ), + child: Padding(padding: const EdgeInsets.all(1), child: child), + ); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: frosted || connected ? null : const [nooDialogShadow], + ), + child: ClipRRect( + borderRadius: radius, + child: frosted + ? BackdropFilter( + filter: ImageFilter.blur( + sigmaX: settings.bottomBarFrostedBlur, + sigmaY: settings.bottomBarFrostedBlur, + ), + child: surface, + ) + : surface, + ), + ); + } +} + +class AvatarNavigationScope extends InheritedNotifier { + final _AvatarNavigationHostState _host; + AvatarNavigationScope._({ + required _AvatarNavigationHostState host, + required super.child, + }) : _host = host, + super(notifier: host.animation); + static AvatarNavigationScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + double get progress => + Curves.easeInOutCubicEmphasized.transform(_host.animation.value); + bool get bottom => _host.bottom; + void close() => _host.close(); + @override + bool updateShouldNotify(AvatarNavigationScope oldWidget) => true; +} + +/// Keeps the top avatar's layout slot while the single animated avatar moves +/// into the card. Its own context gives us the real screen-space origin. +class AvatarNavigationAnchor extends StatelessWidget { + final Widget child; + const AvatarNavigationAnchor({super.key, required this.child}); + @override + Widget build(BuildContext context) { + final scope = AvatarNavigationScope.maybeOf(context); + return IgnorePointer( + ignoring: scope != null && scope.progress > 0, + child: ClipRect( + child: Align( + alignment: Alignment.centerRight, + widthFactor: 1 - (scope?.progress ?? 0), + child: Opacity( + opacity: scope != null && scope.progress > 0 ? 0 : 1, + child: child, + ), + ), + ), + ); + } +} + +/// Only the content pane moves; the bottom navigation stays anchored. +class AvatarNavigationBody extends StatelessWidget { + final Widget child; + const AvatarNavigationBody({super.key, required this.child}); + @override + Widget build(BuildContext context) { + final scope = AvatarNavigationScope.maybeOf(context); + return Padding( + padding: EdgeInsets.only( + top: scope == null || scope.bottom + ? 0 + : (scope._host.cardHeight + 12) * scope.progress, + ), + child: child, + ); + } +} + +class AvatarNavigationSatellite extends StatelessWidget { + final double size; + const AvatarNavigationSatellite({super.key, required this.size}); + @override + Widget build(BuildContext context) { + final scope = AvatarNavigationScope.maybeOf(context); + if (context.watch().bottomBarStyle == + NooBottomBarStyle.attached) { + return Builder( + builder: (anchorContext) => ShellAvatarButton( + hitBox: size, + label: 'Menu', + onTap: () => showAvatarMenu(anchorContext), + ), + ); + } + return IgnorePointer( + ignoring: scope != null && scope.progress > 0, + child: Opacity( + opacity: scope != null && scope.progress > 0 ? 0 : 1, + child: SizedBox( + width: size, + height: size, + child: _AvatarSurface( + radius: BorderRadius.circular(size / 2), + child: Builder( + builder: (anchorContext) => ShellAvatarButton( + hitBox: size, + label: 'Menu', + onTap: () => showAvatarMenu(anchorContext), ), ), ), @@ -287,6 +406,286 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> { } } +class AvatarNavigationHost extends StatefulWidget { + final Widget child; + const AvatarNavigationHost({super.key, required this.child}); + @override + State createState() => _AvatarNavigationHostState(); +} + +class _AvatarNavigationHostState extends State + with SingleTickerProviderStateMixin { + late final AnimationController animation = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + reverseDuration: const Duration(milliseconds: 320), + ); + final GlobalKey measureKey = GlobalKey(); + final GlobalKey measureAvatarKey = GlobalKey(); + Offset avatarOffset = const Offset(0, 17); + Rect? origin; + bool bottom = false; + bool expanded = false; + double cardHeight = 150; + bool measuring = false; + void measure() { + if (measuring) return; + measuring = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + measuring = false; + if (!mounted) return; + final box = measureKey.currentContext?.findRenderObject() as RenderBox?; + final avatarBox = + measureAvatarKey.currentContext?.findRenderObject() as RenderBox?; + final offset = box != null && avatarBox != null + ? avatarBox.localToGlobal(Offset.zero, ancestor: box) + : avatarOffset; + if (box != null && + ((box.size.height - cardHeight).abs() > .5 || + (offset - avatarOffset).distance > .5)) { + setState(() { + cardHeight = box.size.height; + avatarOffset = offset; + }); + } + }); + } + + void openFrom(BuildContext context) { + if (animation.value > 0) { + close(); + return; + } + final settings = context.read(); + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + final hostBox = this.context.findRenderObject() as RenderBox; + final center = hostBox.globalToLocal( + box.localToGlobal(box.size.center(Offset.zero)), + ); + setState(() { + bottom = settings.avatarPosition == AvatarPosition.bottom; + final diameter = bottom + ? NooBottomBar.rowHeight( + NooLayout.navStyle(context), + settings.bottomBarStyle, + ) + : 32.0; + origin = Rect.fromCenter( + center: center, + width: diameter, + height: diameter, + ); + expanded = false; + }); + animation.duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 420); + animation.forward(); + } + + void close() { + animation.reverseDuration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 320); + animation.reverse(); + } + + @override + void dispose() { + animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final session = context.watch(); + final enabled = settings.navMenuStyle == NooNavMenuStyle.avatarMenu; + return AvatarNavigationScope._( + host: this, + child: AnimatedBuilder( + animation: animation, + builder: (context, _) { + final progress = Curves.easeInOutCubicEmphasized.transform( + animation.value, + ); + return PopScope( + canPop: animation.value == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) close(); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + final safeTop = MediaQuery.viewPaddingOf(context).top; + final attached = + bottom && + settings.bottomBarStyle == NooBottomBarStyle.attached; + final bottomEdge = bottom && origin != null + ? origin!.center.dy - + NooBottomBar.rowHeight( + NooLayout.navStyle(context), + settings.bottomBarStyle, + ) / + 2 - + (attached ? 0 : 12) + : height; + final maxHeight = (bottomEdge - safeTop - 24).clamp( + 72.0, + height * .8, + ); + measure(); + Widget card({bool measurement = false}) => ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: AvatarMenuCard( + bottom: bottom, + movingAvatar: measurement || progress < 1, + headerAvatarKey: measurement ? measureAvatarKey : null, + showHeaderAvatar: !attached, + expanded: expanded, + onExpand: () => setState(() => expanded = !expanded), + onClose: close, + ), + ); + final target = Rect.fromLTWH( + attached ? 0 : 12, + bottom ? bottomEdge - cardHeight : safeTop + 8, + attached ? width : width - 24, + cardHeight, + ); + final rect = Rect.lerp( + attached + ? Rect.fromLTWH(0, bottomEdge, width, 0) + : origin ?? target, + target, + progress, + )!; + final radius = attached + ? BorderRadius.vertical( + top: Radius.circular(NooRadii.card * progress), + ) + : BorderRadius.circular( + lerpDouble( + (origin?.width ?? 32) / 2, + NooRadii.card, + progress, + )!, + ); + final avatarTarget = Rect.fromLTWH( + target.right - 49, + target.top + avatarOffset.dy, + 32, + 32, + ); + final avatar = Rect.lerp( + origin == null + ? avatarTarget + : Rect.fromCenter( + center: origin!.center, + width: 32, + height: 32, + ), + avatarTarget, + progress, + )!; + return Stack( + children: [ + widget.child, + if (enabled) + Positioned( + left: attached ? 0 : 12, + right: attached ? 0 : 12, + top: safeTop, + child: Offstage( + child: + NotificationListener< + SizeChangedLayoutNotification + >( + onNotification: (_) { + measure(); + return false; + }, + child: SizeChangedLayoutNotifier( + child: KeyedSubtree( + key: measureKey, + child: card(measurement: true), + ), + ), + ), + ), + ), + if (enabled && progress > 0 && origin != null) ...[ + Positioned.fill( + bottom: bottom ? height - bottomEdge : 0, + child: Semantics( + label: 'Close menu', + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: close, + child: const ColoredBox(color: Colors.transparent), + ), + ), + ), + Positioned.fromRect( + rect: rect, + child: _AvatarSurface( + radius: radius, + connected: attached, + child: ClipRRect( + borderRadius: radius, + child: OverflowBox( + alignment: bottom + ? Alignment.bottomRight + : Alignment.topRight, + minWidth: target.width, + maxWidth: target.width, + minHeight: target.height, + maxHeight: target.height, + child: IgnorePointer( + ignoring: progress < .95, + child: Opacity( + opacity: ((progress - .3) / .7).clamp( + 0.0, + 1.0, + ), + child: card(), + ), + ), + ), + ), + ), + ), + if (!attached && progress < 1) + Positioned.fromRect( + rect: avatar, + child: Semantics( + label: 'Close menu', + button: true, + child: GestureDetector( + onTap: close, + child: NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 32, + ), + ), + ), + ), + ], + ], + ); + }, + ), + ); + }, + ), + ); + } +} + class _OtherAccountRow extends StatelessWidget { final String name; final String host; @@ -331,8 +730,7 @@ class _OtherAccountRow extends StatelessWidget { ), ), const SizedBox(width: 12), - NooAvatar(initials: accountInitial(name), current: false, size: 40), - const SizedBox(width: 44), + NooAvatar(initials: accountInitial(name), current: false, size: 32), ], ), ), diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index f116a38..f920a1b 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; import 'noo/nav/noo_bottom_bar.dart'; +import 'avatar_menu.dart'; /// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic /// [NooNavDestination] list, so `MainShellView` only deals in tabs and @@ -16,19 +17,27 @@ import 'noo/nav/noo_bottom_bar.dart'; class BottomNavBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; + final bool frosted; + final double frostedBlur; + final double frostedOpacity; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; final VoidCallback? onSearchTap; + final bool avatarInBottomBar; const BottomNavBar({ super.key, required this.style, this.barStyle = NooBottomBarStyle.attached, + this.frosted = false, + this.frostedBlur = NooBottomBar.defaultFrostedBlur, + this.frostedOpacity = NooBottomBar.defaultFrostedOpacity, required this.tabs, required this.selectedIndex, required this.onDestinationSelected, this.onSearchTap, + this.avatarInBottomBar = false, }); @override @@ -36,16 +45,35 @@ class BottomNavBar extends StatelessWidget { return NooBottomBar( style: style, barStyle: barStyle, + frosted: frosted, + frostedBlur: frostedBlur, + frostedOpacity: frostedOpacity, destinations: [ for (final tab in tabs) NooNavDestination(icon: tab.icon, label: tab.label), ], selectedIndex: selectedIndex, - onSelected: onDestinationSelected, + onSelected: (index) { + AvatarNavigationScope.maybeOf(context)?.close(); + onDestinationSelected(index); + }, searchDestination: onSearchTap == null ? null : const NooNavDestination(icon: LucideIcons.search, label: 'Search'), - onSearchTap: onSearchTap, + onSearchTap: onSearchTap == null + ? null + : () { + AvatarNavigationScope.maybeOf(context)?.close(); + onSearchTap!(); + }, + avatarSatellite: avatarInBottomBar + ? AvatarNavigationSatellite( + size: NooBottomBar.rowHeight(style, barStyle), + ) + : null, + avatarMenuProgress: avatarInBottomBar + ? AvatarNavigationScope.maybeOf(context)?.progress ?? 0 + : 0, ); } } diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index abde3ee..f2db4a6 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -5,7 +5,7 @@ import '../providers/files_controller.dart'; import '../theme/design_tokens.dart'; import 'noo/core/noo_chip.dart'; import 'noo/core/noo_segmented_control.dart'; -import 'noo/core/noo_toggle.dart'; +import 'filter_mode_rows.dart'; import 'noo/lists/noo_grouped_list.dart'; import 'noo/lists/noo_settings_row.dart'; import 'noo/overlays/noo_sheet.dart'; @@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget { final files = context.watch(); final filtersActive = files.filesTypeFilter != FilesTypeFilter.all || - files.showHiddenFiles || + files.hiddenFilter != HiddenFilesFilter.hide || (showStorageScope && files.storageScope != StorageScope.cloud); return SizedBox( @@ -171,54 +171,46 @@ class FilesControlsRow extends StatelessWidget { // Same "icon always, label only when selected" pill as // Photos' own type filter below - not a checkmark list, so // the two screens' filter sheets look and behave the same. - NooSegmentedControl( - fill: true, - onSurface: true, - labelOnlySelected: true, - value: files.filesTypeFilter, - onChanged: files.setFilesTypeFilter, - options: const [ - NooSegmentOption( - value: FilesTypeFilter.all, - icon: LucideIcons.layoutGrid, - label: 'All', - ), - NooSegmentOption( - value: FilesTypeFilter.filesOnly, - icon: LucideIcons.file, - label: 'Files', - ), - NooSegmentOption( - value: FilesTypeFilter.foldersOnly, - icon: LucideIcons.folder, - label: 'Folders', - ), - ], + FilterSection( + icon: LucideIcons.layoutGrid, + title: 'Type', + child: NooSegmentedControl( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: files.filesTypeFilter, + onChanged: files.setFilesTypeFilter, + options: const [ + NooSegmentOption( + value: FilesTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', + ), + NooSegmentOption( + value: FilesTypeFilter.filesOnly, + icon: LucideIcons.file, + label: 'Files', + ), + NooSegmentOption( + value: FilesTypeFilter.foldersOnly, + icon: LucideIcons.folder, + label: 'Folders', + ), + ], + ), ), const SizedBox(height: 22), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: files.showHiddenFiles, - onChanged: (_) => files.toggleShowHiddenFiles(), - ), - ), - if (showStorageScope) - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, - ), - ), - ), - ], + HiddenFilesFilterRow( + value: files.hiddenFilter, + onChanged: files.setHiddenFilter, ), + if (showStorageScope) ...[ + const SizedBox(height: 22), + StorageScopeRow( + value: files.storageScope, + onChanged: files.setStorageScope, + ), + ], ], ), ), diff --git a/lib/widgets/filter_mode_rows.dart b/lib/widgets/filter_mode_rows.dart new file mode 100644 index 0000000..a02eba3 --- /dev/null +++ b/lib/widgets/filter_mode_rows.dart @@ -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 onChanged; + + const HiddenFilesFilterRow({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterModeRow( + 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 onChanged; + + const StorageScopeRow({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterModeRow( + 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 extends StatelessWidget { + final IconData icon; + final String title; + final T value; + final ValueChanged onChanged; + final List> 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( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: value, + onChanged: onChanged, + options: options, + ), + ); + } +} diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart index 7b373fb..7de929b 100644 --- a/lib/widgets/frosted_glass_container.dart +++ b/lib/widgets/frosted_glass_container.dart @@ -1,18 +1,11 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/settings_controller.dart'; import '../theme/design_tokens.dart'; -/// The blurred/translucent chrome for the media viewer's overlay bars -/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video -/// transport row) - the one deliberate exception to the design system's -/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome -/// floats over photo/video content rather than over the app's own -/// surfaces. No shadow, no outline: [color] defaults to the app's own -/// `surface` token (so this panel reads as light or dark to match the -/// active theme, like the rest of the media viewer, instead of a fixed -/// dark tone regardless of theme) at a higher-than-usual [opacity], since -/// a light tint needs denser coverage than a near-black one did to stay -/// legible over arbitrary photo/video brightness underneath. +/// Media viewer chrome that shares the navigation frost preference. +/// Turning frosting off produces an opaque themed surface without blur. class FrostedGlassContainer extends StatelessWidget { final Widget child; final double borderRadius; @@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget { /// Overrides [borderRadius] for non-uniform corners (e.g. top-only). final BorderRadius? radius; - /// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an - /// explicit value (e.g. from user settings) to make it adjustable. - final double blurSigma; + /// Blur sigma for the backdrop filter. Defaults to the shared frost setting. + final double? blurSigma; - /// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed - /// value; pass an explicit value (e.g. from user settings) to make it - /// adjustable. - final double opacity; + /// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting. + final double? opacity; + final bool? frosted; /// The tint under the blur. Defaults to [NooColors.surface] (resolved at /// build time, so it always matches the active theme) - pass an explicit @@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget { required this.child, this.borderRadius = 0, this.radius, - this.blurSigma = 20, - this.opacity = 0.8, + this.blurSigma, + this.opacity, + this.frosted, this.color, }); @@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget { Widget build(BuildContext context) { final shape = radius ?? BorderRadius.circular(borderRadius); final tint = color ?? context.nooColors.surface; + final settings = context.watch(); + final enabled = frosted ?? settings?.bottomBarFrosted ?? false; + final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20; + final fill = Container( + decoration: BoxDecoration( + color: enabled + ? tint.withValues( + alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72, + ) + : tint, + borderRadius: shape, + ), + child: child, + ); return ClipRRect( borderRadius: shape, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), - child: Container( - decoration: BoxDecoration( - color: tint.withValues(alpha: opacity), - borderRadius: shape, - ), - child: child, - ), - ), + child: enabled + ? BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: fill, + ) + : fill, ); } } diff --git a/lib/widgets/noo/core/noo_button.dart b/lib/widgets/noo/core/noo_button.dart index 53a19d2..4c32105 100644 --- a/lib/widgets/noo/core/noo_button.dart +++ b/lib/widgets/noo/core/noo_button.dart @@ -1,7 +1,15 @@ import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; -enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger } +enum NooButtonVariant { + primary, + tonal, + secondary, + danger, + outline, + text, + textDanger, +} enum NooButtonSize { cta, card, field, toolbar, compact, xs } @@ -37,6 +45,14 @@ class NooButton extends StatefulWidget { final bool disabled; final VoidCallback? onTap; + /// A round, icon-only button (no label) - [icon] centred in a circle as + /// wide as the size's height, instead of the pill's side padding. Callers + /// are responsible for a tooltip/semantic label, since there's no text. + final bool iconOnly; + + /// Continuous icon-only to labelled expansion for animated shell actions. + final double? expansion; + const NooButton({ super.key, this.variant = NooButtonVariant.primary, @@ -46,6 +62,8 @@ class NooButton extends StatefulWidget { this.fullWidth = false, this.disabled = false, this.onTap, + this.iconOnly = false, + this.expansion, }); @override @@ -87,17 +105,29 @@ class _NooButtonState extends State { final colors = context.nooColors; final s = _sizes[widget.size]!; final (bg, fg, border) = _colors(colors); - final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant; + final hasLeadingIcon = + widget.icon != null && widget.child != null && !_isTextVariant; final content = Row( mainAxisSize: MainAxisSize.min, children: [ if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg), - if (widget.icon != null && widget.child != null) SizedBox(width: s.gap), - if (widget.child != null) - DefaultTextStyle( - style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg), - child: widget.child!, + if (widget.icon != null && widget.child != null) + SizedBox(width: s.gap * (widget.expansion ?? 1)), + if (widget.child != null && widget.expansion != 0) + ClipRect( + child: Align( + alignment: Alignment.centerLeft, + widthFactor: widget.expansion ?? 1, + heightFactor: 1, + child: Opacity( + opacity: widget.expansion ?? 1, + child: DefaultTextStyle( + style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg), + child: widget.child!, + ), + ), + ), ), ], ); @@ -107,7 +137,9 @@ class _NooButtonState extends State { child: GestureDetector( onTapDown: widget.disabled ? null : (_) => setState(() => _down = true), onTapUp: widget.disabled ? null : (_) => setState(() => _down = false), - onTapCancel: widget.disabled ? null : () => setState(() => _down = false), + onTapCancel: widget.disabled + ? null + : () => setState(() => _down = false), onTap: widget.disabled ? null : widget.onTap, child: AnimatedScale( scale: _down && !widget.disabled ? NooMotion.pressScale : 1, @@ -115,8 +147,20 @@ class _NooButtonState extends State { curve: NooMotion.ease, child: Container( height: s.height, - width: widget.fullWidth ? double.infinity : null, - padding: _isTextVariant + width: widget.iconOnly + ? s.height + : (widget.fullWidth ? double.infinity : null), + padding: widget.expansion != null + ? EdgeInsets.only( + left: + (s.height - s.icon) / 2 + + (s.px - 4 - (s.height - s.icon) / 2) * + widget.expansion!, + right: + (s.height - s.icon) / 2 + + (s.px - (s.height - s.icon) / 2) * widget.expansion!, + ) + : _isTextVariant || widget.iconOnly ? EdgeInsets.zero : EdgeInsets.only( left: hasLeadingIcon ? s.px - 4 : s.px, @@ -128,7 +172,9 @@ class _NooButtonState extends State { // of shrink-wrapping to it, which is what stretched this button // edge-to-edge instead of sizing to its content (see NooFab's // doc comment for the same bug). - alignment: widget.fullWidth ? Alignment.center : null, + alignment: widget.fullWidth || widget.iconOnly + ? Alignment.center + : null, decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(NooRadii.pill), diff --git a/lib/widgets/noo/core/noo_slider.dart b/lib/widgets/noo/core/noo_slider.dart new file mode 100644 index 0000000..ae9fc59 --- /dev/null +++ b/lib/widgets/noo/core/noo_slider.dart @@ -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? 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, + ), + ); + } +} diff --git a/lib/widgets/noo/files/noo_swipe_action.dart b/lib/widgets/noo/files/noo_swipe_action.dart index 7eb9ce9..e729ad7 100644 --- a/lib/widgets/noo/files/noo_swipe_action.dart +++ b/lib/widgets/noo/files/noo_swipe_action.dart @@ -1,23 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../models/selection_action.dart'; import '../../../theme/design_tokens.dart'; -/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2, -/// "Swipe action"); which one sits on which side is a user setting -/// (Settings -> Swipe on a file). -enum NooSwipeActionKind { delete, favorite } +/// Swipe blocks share the action bar's identities, icons and default labels. +typedef NooSwipeActionKind = SelectionActionKind; /// One side's action for [NooSwipeAction]. class NooSwipeActionSpec { final NooSwipeActionKind kind; final VoidCallback onTriggered; - /// Overrides the default label ("Delete" / "Favorite"), e.g. + /// Overrides the action's default label, e.g. /// "Unfavorite" for an already-starred file. final String? label; - /// Overrides the default icon (`trash-2` / `star`). + /// Overrides the action's default icon. final IconData? icon; const NooSwipeActionSpec({ @@ -30,7 +28,7 @@ class NooSwipeActionSpec { /// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides /// it aside to uncover a 96px action block: delete is white on -/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a +/// `danger-fill`, other actions are white on `accent`, each a 20px icon over a /// 12/600 label. Releasing past half the block snaps it open, otherwise it /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// Tapping the block fires the action and closes it; tapping the row while @@ -256,9 +254,8 @@ class _ActionBlock extends StatelessWidget { Widget build(BuildContext context) { final isDelete = spec.kind == NooSwipeActionKind.delete; final bg = isDelete ? colors.dangerFill : colors.accent; - final icon = - spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star); - final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite'); + final icon = spec.icon ?? spec.kind.icon; + final label = spec.label ?? spec.kind.label; return GestureDetector( behavior: HitTestBehavior.opaque, diff --git a/lib/widgets/noo/lists/noo_selection_bar.dart b/lib/widgets/noo/lists/noo_selection_bar.dart index c782849..c271cc5 100644 --- a/lib/widgets/noo/lists/noo_selection_bar.dart +++ b/lib/widgets/noo/lists/noo_selection_bar.dart @@ -23,7 +23,8 @@ const int _kDesktopInlineActions = 4; /// Replaces the bare row of Material `IconButton`s each screen used to /// build ad hoc. /// -/// A `surface` card (radius 20) - selection reads as a distinct mode, not +/// A floating `surface` card (radius 20), with the bottom bar's border +/// and shadow - selection reads as a distinct mode, not /// just a row of buttons floating on `bg` - with a close circle, the /// count, and the bulk actions. This sits in the *content* pane's own /// pinned header slot on every platform (the shell's desktop toolbar, @@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget { decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card), + border: Border.all(color: colors.line), + boxShadow: const [nooDialogShadow], ), child: Row( children: [ diff --git a/lib/widgets/noo/lists/noo_settings_row.dart b/lib/widgets/noo/lists/noo_settings_row.dart index b5e3fd6..2906c8a 100644 --- a/lib/widgets/noo/lists/noo_settings_row.dart +++ b/lib/widgets/noo/lists/noo_settings_row.dart @@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget { final IconData? icon; final Widget label; final Widget? subtitle; + /// Renders as `value` text + a trailing chevron - mutually exclusive with /// [trailing] (a `NooToggle`, button, badge, etc.). final String? value; final Widget? trailing; final bool danger; + /// Accent-text action row, e.g. "Add account". final bool accent; final VoidCallback? onTap; + final Color? backgroundColor; const NooSettingsRow({ super.key, @@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget { this.danger = false, this.accent = false, this.onTap, + this.backgroundColor, }); @override Widget build(BuildContext context) { final colors = context.nooColors; - final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1); + final color = danger + ? colors.danger + : (accent ? colors.accentText : colors.fg1); final minHeight = subtitle != null ? 60.0 : 52.0; Widget? trail = trailing; @@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget { children: [ Text( value!, - style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3), + style: NooText.body.copyWith( + fontSize: 15, + height: 1, + color: colors.fg3, + ), ), const SizedBox(width: 4), Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3), @@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget { } return Material( - color: colors.surface, + color: backgroundColor ?? colors.surface, child: InkWell( onTap: onTap, child: ConstrainedBox( @@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget { child: Row( children: [ if (icon != null) ...[ - Icon(icon, size: 20, color: danger || accent ? color : colors.fg2), + Icon( + icon, + size: 20, + color: danger || accent ? color : colors.fg2, + ), const SizedBox(width: 14), ], Expanded( diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 61fbbd7..8fd0ded 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; import 'noo_nav_style.dart'; @@ -18,14 +19,17 @@ const double _kAndroidPillHeight = 32; /// what order, is the caller's business; this only draws [destinations]. /// /// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top -/// padding) of icon 24 over a 10px label. Active: accent-text, 600. -/// Idle: fg-3, 500. No ripple. +/// padding) of icon 24 over a 10px selected label. Active: accent-text, 600. +/// Idle: centered fg-3 icon, no label. No ripple. /// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px /// icon over a 12px label (label only visible - not removed from layout, /// so the row never resizes - once selected). The active icon sits in a /// 56x32 accent-soft pill that slides between tabs as selection moves, /// rather than popping in/out on the destination item itself. /// +/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android +/// row below, so iOS gets the same icon-only idle tabs. +/// /// [barStyle] (user-configurable in Settings, Appearance) picks between /// that edge-to-edge [NooBottomBarStyle.attached] bar and /// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px @@ -45,6 +49,13 @@ const double _kAndroidPillHeight = 32; /// body draws behind it - see `tab_state_slivers.dart`'s /// `bottomBarClearance`. /// +/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a +/// translucent one over a backdrop blur, on both bar styles and both nav +/// styles, and drops [nooDialogShadow] (a shadow would show through the +/// glass). It only reads as glass if content draws behind the bar, so the +/// host `Scaffold` needs `extendBody: true` and each scrollable body needs +/// the same trailing clearance floating does - see [drawsBehindBody]. +/// /// [searchDestination]/[onSearchTap] (set together, from Settings' /// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar /// entry) add a Search entry that's never highlighted (tapping it pushes @@ -54,31 +65,59 @@ const double _kAndroidPillHeight = 32; class NooBottomBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; + final bool frosted; + + /// Backdrop blur sigma and surface-fill opacity while [frosted] (user- + /// adjustable in Settings; the defaults match [FrostedGlassContainer]'s + /// blur). + final double frostedBlur; + final double frostedOpacity; final List destinations; final int selectedIndex; final ValueChanged onSelected; final NooNavDestination? searchDestination; final VoidCallback? onSearchTap; + /// Bottom-avatar navigation occupies this slot while closed. Search moves + /// from the row into the slot as the avatar expands into its popup. + final Widget? avatarSatellite; + final double avatarMenuProgress; + const NooBottomBar({ super.key, required this.style, this.barStyle = NooBottomBarStyle.attached, + this.frosted = false, + this.frostedBlur = defaultFrostedBlur, + this.frostedOpacity = defaultFrostedOpacity, required this.destinations, required this.selectedIndex, required this.onSelected, this.searchDestination, this.onSearchTap, + this.avatarSatellite, + this.avatarMenuProgress = 0, }); /// The row height [build] draws for [style]/[barStyle] - exposed so a /// scrollable body sharing the same `Scaffold` can reserve exactly this /// much clearance (see the class doc comment) instead of guessing. static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) { - if (style == NooNavStyle.ios) return 50; + if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) { + return 50; + } return barStyle == NooBottomBarStyle.floating ? 64 : 80; } + static const double defaultFrostedBlur = 20; + static const double defaultFrostedOpacity = 0.72; + + /// Whether the host `Scaffold` must draw its body behind the bar + /// (`extendBody`) - true for floating (its transparent margin) and for any + /// frosted bar (there's nothing to blur otherwise). + static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) => + frosted || barStyle == NooBottomBarStyle.floating; + /// Gap between the floating bar's bottom edge and the safe area below it /// (itself inside the [SafeArea] that consumes the actual device inset). static const double floatingBottomMargin = 12; @@ -86,13 +125,20 @@ class NooBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { final colors = context.nooColors; - final ios = style == NooNavStyle.ios; final floating = barStyle == NooBottomBarStyle.floating; + // Floating always gets the Android row (icon-only idle tabs, sliding + // pill) - iOS's icon-over-label row doesn't fit a pill that short. + final ios = style == NooNavStyle.ios && !floating; final hasSearch = searchDestination != null && onSearchTap != null; // Attached folds Search into the row itself (last item); floating // gives it a separate satellite circle instead (built below), so the // row builders only ever see it as a trailing item in the former case. - final rowSearch = hasSearch && !floating ? searchDestination : null; + final rowSearch = + hasSearch && + (!floating || avatarSatellite != null) && + (avatarSatellite == null || !floating || avatarMenuProgress < 1) + ? searchDestination + : null; final row = ios ? _buildIosRow(trailingSearch: rowSearch) @@ -104,17 +150,21 @@ class NooBottomBar extends StatelessWidget { final barHeight = NooBottomBar.rowHeight(style, barStyle); if (floating) { - final pill = Container( + final pillRadius = BorderRadius.circular(28); + final pillBox = Container( height: barHeight, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: colors.surface, + color: _fill(colors), border: Border.all(color: colors.line), - borderRadius: BorderRadius.circular(28), - boxShadow: const [nooDialogShadow], + borderRadius: pillRadius, + boxShadow: frosted ? null : const [nooDialogShadow], ), child: row, ); + final pill = frosted + ? _blurred(pillBox, pillRadius, frostedBlur) + : pillBox; return SafeArea( top: false, child: Padding( @@ -124,7 +174,9 @@ class NooBottomBar extends StatelessWidget { 16, NooBottomBar.floatingBottomMargin, ), - child: hasSearch + child: avatarSatellite != null + ? _withAvatar(pill, barHeight, hasSearch) + : hasSearch // A fixed-height SizedBox, not just a Row with // crossAxisAlignment.stretch - the bottomNavigationBar slot // gives this widget a *loose* (unbounded-max) height @@ -145,6 +197,9 @@ class NooBottomBar extends StatelessWidget { destination: searchDestination!, onTap: onSearchTap!, size: barHeight, + frosted: frosted, + frostedBlur: frostedBlur, + frostedOpacity: frostedOpacity, ), ], ), @@ -154,39 +209,133 @@ class NooBottomBar extends StatelessWidget { ); } - return Container( + final bar = Container( decoration: BoxDecoration( - color: colors.surface, + color: _fill(colors), border: ios ? Border(top: BorderSide(color: colors.line)) : null, ), child: SafeArea( top: false, - child: SizedBox(height: barHeight, child: row), + child: SizedBox( + height: barHeight, + child: avatarSatellite == null + ? row + : Row( + children: [ + Expanded(child: row), + SizedBox( + width: barHeight, + height: barHeight, + child: avatarSatellite, + ), + const SizedBox(width: 8), + ], + ), + ), + ), + ); + return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar; + } + + Widget _withAvatar(Widget rail, double height, bool hasSearch) { + final satelliteWidth = hasSearch + ? height + : height * (1 - avatarMenuProgress); + return SizedBox( + height: height, + child: Row( + children: [ + Expanded(child: rail), + SizedBox(width: 8 * (hasSearch ? 1 : 1 - avatarMenuProgress)), + SizedBox( + width: satelliteWidth, + height: height, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.centerRight, + minWidth: height, + maxWidth: height, + child: Stack( + children: [ + avatarSatellite!, + if (hasSearch && avatarMenuProgress > 0) + Opacity( + opacity: avatarMenuProgress, + child: _SearchSatellite( + destination: searchDestination!, + onTap: onSearchTap!, + size: height, + frosted: frosted, + frostedBlur: frostedBlur, + frostedOpacity: frostedOpacity, + ), + ), + ], + ), + ), + ), + ), + ], ), ); } + Color _fill(NooColors colors) => frosted + ? colors.surface.withValues(alpha: frostedOpacity) + : colors.surface; + + double get _rowSearchFraction => + avatarSatellite == null || barStyle == NooBottomBarStyle.attached + ? 1 + : 1 - avatarMenuProgress; + + Widget _searchRowItem(Widget child, double width) => SizedBox( + width: width * _rowSearchFraction, + child: IgnorePointer( + ignoring: _rowSearchFraction < .95, + child: ClipRect( + child: OverflowBox( + minWidth: width, + maxWidth: width, + child: Opacity(opacity: _rowSearchFraction, child: child), + ), + ), + ), + ); + Widget _buildIosRow({NooNavDestination? trailingSearch}) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < destinations.length; i++) - Expanded( - child: _IosItem( - destination: destinations[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), - ), - ), - if (trailingSearch != null) - Expanded( - child: _IosItem( - destination: trailingSearch, - selected: false, - onTap: onSearchTap!, - ), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + final width = + constraints.maxWidth / + (destinations.length + + (trailingSearch == null ? 0 : _rowSearchFraction)); + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + SizedBox( + width: width, + child: _IosItem( + destination: destinations[i], + selected: i == selectedIndex, + selectedLabelOnly: true, + onTap: () => onSelected(i), + ), + ), + if (trailingSearch != null) + _searchRowItem( + _IosItem( + destination: trailingSearch, + selected: false, + selectedLabelOnly: true, + onTap: onSearchTap!, + ), + width, + ), + ], + ); + }, ); } @@ -196,7 +345,8 @@ class NooBottomBar extends StatelessWidget { NooNavDestination? trailingSearch, }) { final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; - final itemCount = destinations.length + (trailingSearch != null ? 1 : 0); + final itemCount = + destinations.length + (trailingSearch != null ? _rowSearchFraction : 0); return LayoutBuilder( builder: (context, constraints) { final itemWidth = constraints.maxWidth / itemCount; @@ -222,7 +372,8 @@ class NooBottomBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < destinations.length; i++) - Expanded( + SizedBox( + width: itemWidth, child: _AndroidItem( destination: destinations[i], selected: i == selectedIndex, @@ -231,13 +382,14 @@ class NooBottomBar extends StatelessWidget { ), ), if (trailingSearch != null) - Expanded( - child: _AndroidItem( + _searchRowItem( + _AndroidItem( destination: trailingSearch, selected: false, floating: floating, onTap: onSearchTap!, ), + itemWidth, ), ], ), @@ -248,6 +400,16 @@ class NooBottomBar extends StatelessWidget { } } +/// Clips [child] to [radius] and blurs whatever is drawn behind it - the +/// frosted bar's backdrop. [child] supplies the translucent fill on top. +Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect( + borderRadius: radius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: child, + ), +); + /// The floating bar's own Search entry point, next to the pill rather than /// inside it - always fully round (see [NooBottomBar]'s doc comment), /// same fill/border as the pill so the two still read as one family. @@ -255,11 +417,17 @@ class _SearchSatellite extends StatelessWidget { final NooNavDestination destination; final VoidCallback onTap; final double size; + final bool frosted; + final double frostedBlur; + final double frostedOpacity; const _SearchSatellite({ required this.destination, required this.onTap, required this.size, + required this.frosted, + required this.frostedBlur, + required this.frostedOpacity, }); @override @@ -271,31 +439,42 @@ class _SearchSatellite extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.surface, - border: Border.all(color: colors.line), - shape: BoxShape.circle, - boxShadow: const [nooDialogShadow], - ), - child: Icon(destination.icon, size: 24, color: colors.fg1), - ), + child: _satelliteBody(colors), ), ); } + + Widget _satelliteBody(NooColors colors) { + final disc = Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: frosted + ? colors.surface.withValues(alpha: frostedOpacity) + : colors.surface, + border: Border.all(color: colors.line), + shape: BoxShape.circle, + boxShadow: frosted ? null : const [nooDialogShadow], + ), + child: Icon(destination.icon, size: 24, color: colors.fg1), + ); + return frosted + ? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur) + : disc; + } } class _IosItem extends StatelessWidget { final NooNavDestination destination; final bool selected; + final bool selectedLabelOnly; final VoidCallback onTap; const _IosItem({ required this.destination, required this.selected, + this.selectedLabelOnly = false, required this.onTap, }); @@ -306,30 +485,35 @@ class _IosItem extends StatelessWidget { return Semantics( button: true, selected: selected, + label: selectedLabelOnly && !selected ? destination.label : null, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(destination.icon, size: 24, color: fg), - const SizedBox(height: 4), - Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.navLabel.copyWith( - fontSize: 10, - height: 1, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: fg, + child: selectedLabelOnly && !selected + ? Center(child: Icon(destination.icon, size: 24, color: fg)) + : Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(destination.icon, size: 24, color: fg), + const SizedBox(height: 4), + Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.navLabel.copyWith( + fontSize: 10, + height: 1, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + color: fg, + ), + ), + ], ), ), - ], - ), - ), ), ); } diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart index dbffcb7..f1c9d84 100644 --- a/lib/widgets/noo/nav/noo_nav_style.dart +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating } /// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu` /// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu] /// drops that icon entirely and repurposes the avatar button - already -/// sitting top-right, already a tap target every user already knows - as -/// the one entry point instead, opening a dropdown anchored below it (see +/// available at the top or bottom by user preference - as the one entry +/// point instead, expanding into an anchored navigation card (see /// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer /// sliding from the opposite edge. Applies on both platforms, since /// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index b43caae..cd10903 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { /// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on /// Android) through [IconTheme]. final List actions; + final bool iosActionsInTitleRow; /// iOS only: the search field shown below the large title. final Widget? search; @@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { this.leading, this.onMenu, this.actions = const [], + this.iosActionsInTitleRow = false, this.search, this.androidTitleTrailing, this.backgroundColor, @@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { @override Size get preferredSize { if (!_ios) return const Size.fromHeight(_androidRow); + if (iosActionsInTitleRow) { + return Size.fromHeight( + 64 + (search != null ? _iosSearch + _iosSearchBottom : 0), + ); + } return Size.fromHeight( _iosRow + _iosTitleTop + @@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { } Widget _buildIos(NooColors colors, Widget? lead) { + if (iosActionsInTitleRow) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 64, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: lead == null ? 16 : 6, + end: 6, + ), + child: Row( + spacing: 4, + children: [ + ?lead, + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.largeTitle.copyWith( + leadingDistribution: TextLeadingDistribution.even, + color: colors.fg1, + ), + ), + ), + ...actions, + ], + ), + ), + ), + if (search != null) + Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.md, + 0, + NooSpace.md, + _iosSearchBottom, + ), + child: SizedBox(height: _iosSearch, child: search), + ), + ], + ); + } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/widgets/selection_bar_overlay.dart b/lib/widgets/selection_bar_overlay.dart new file mode 100644 index 0000000..6711697 --- /dev/null +++ b/lib/widgets/selection_bar_overlay.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// Pins selection actions above the shell navigation. Scaffold supplies its +/// navigation footprint through MediaQuery padding when the body extends +/// behind navigation; otherwise the body already ends above it. +class SelectionBarOverlay extends StatelessWidget { + static const clearance = 64.0; + + final Widget child; + final Widget? bar; + + const SelectionBarOverlay({super.key, required this.child, this.bar}); + + @override + Widget build(BuildContext context) { + if (bar == null) return child; + return Stack( + fit: StackFit.expand, + children: [ + child, + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.paddingOf(context).bottom + 8, + child: SizedBox(height: 56, child: bar), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart index 0fef176..2c5197b 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -4,8 +4,11 @@ import '../../models/selection_action.dart'; import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/lists/noo_tab_order_row.dart'; +import '../noo/lists/noo_selection_bar.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../noo/lists/noo_grouped_list.dart'; +import '../noo/core/noo_segmented_control.dart'; import '../noo/noo_layout.dart'; -import 'settings_section.dart'; /// Settings section: the priority order bulk actions (favorite, share, /// download, ...) show in on the Files/Photos/Favorites multi-select @@ -13,7 +16,7 @@ import 'settings_section.dart'; /// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4 /// desktop, see its own doc comment) vs. behind its trailing "More" - /// there's no pin/hide concept here the way Tabs has, so this is just a -/// reorder list, not a whole section of its own option rows. +/// position choice followed by a reorder list. class SettingsActionBarSection extends StatelessWidget { const SettingsActionBarSection({super.key}); @@ -33,18 +36,34 @@ class SettingsActionBarSection extends StatelessWidget { final desktop = NooLayout.isDesktop(context); final orderList = _ActionOrderList( + rounded: desktop, order: settings.selectionActionOrder, onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex), ); + final positionRow = NooSettingsRow( + label: const Text('Position'), + trailing: NooSegmentedControl( + onSurface: true, + value: settings.selectionBarPosition, + onChanged: settings.setSelectionBarPosition, + options: const [ + NooSegmentOption(value: SelectionBarPosition.top, label: 'Top'), + NooSegmentOption(value: SelectionBarPosition.bottom, label: 'Bottom'), + ], + ), + ); final colors = context.nooColors; if (!desktop) { - return SettingsSection( - title: 'Action bar', - subtitle: _subtitle, - children: [orderList], + return NooGroupedList( + footer: const Text(_subtitle), + children: [ + _ActionBarPreview(order: settings.selectionActionOrder), + positionRow, + orderList, + ], ); } @@ -65,6 +84,10 @@ class SettingsActionBarSection extends StatelessWidget { const SizedBox(height: 4), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), const SizedBox(height: NooSpace.md), + _ActionBarPreview(order: settings.selectionActionOrder), + const SizedBox(height: NooSpace.md), + positionRow, + const SizedBox(height: NooSpace.md), orderList, ], ), @@ -72,6 +95,55 @@ class SettingsActionBarSection extends StatelessWidget { } } +class _ActionBarPreview extends StatelessWidget { + final List order; + + const _ActionBarPreview({required this.order}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); + return Container( + padding: const EdgeInsets.all(NooSpace.sm), + color: colors.surface2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Preview', style: NooText.meta.copyWith(color: colors.fg3)), + const SizedBox(height: NooSpace.xs), + // Settings has a narrower column than the file browser. Scale the + // real bar to fit rather than changing its inline/overflow split. + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: SizedBox( + width: desktop ? 1000 : 400, + child: NooSelectionBar( + count: 1, + isDesktop: desktop, + iosStyle: NooLayout.iosStyle(context), + onClose: () {}, + actions: [ + for (final kind in order) + SelectionAction( + kind: kind, + icon: kind.icon, + label: kind.label, + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${kind.label} preview')), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + /// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same /// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList` /// draws, minus the pin toggle (`showPin: false`): there's nothing to @@ -79,8 +151,13 @@ class SettingsActionBarSection extends StatelessWidget { class _ActionOrderList extends StatelessWidget { final List order; final void Function(int oldIndex, int newIndex) onReorder; + final bool rounded; - const _ActionOrderList({required this.order, required this.onReorder}); + const _ActionOrderList({ + required this.order, + required this.onReorder, + required this.rounded, + }); @override Widget build(BuildContext context) { @@ -105,12 +182,14 @@ class _ActionOrderList extends StatelessWidget { ); }, ); + final rows = DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: list, + ); + if (!rounded) return rows; return ClipRRect( borderRadius: BorderRadius.circular(NooRadii.card), - child: DecoratedBox( - decoration: BoxDecoration(color: colors.line), - child: list, - ), + child: rows, ); } } diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 23ce453..ca9f89c 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart'; import '../../theme/app_theme.dart'; import '../../theme/design_tokens.dart'; import '../noo/core/noo_segmented_control.dart'; +import '../noo/core/noo_slider.dart'; import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; import '../noo/nav/noo_nav_style.dart'; @@ -53,7 +54,36 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ _ThemeRow(settings: settings), _BottomBarStyleRow(settings: settings), + NooSettingsRow( + icon: LucideIcons.glassWater, + label: const Text('Frosted glass'), + subtitle: const Text( + 'Blurred bottom navigation, avatar menus and media viewer panels', + ), + trailing: NooToggle( + checked: settings.bottomBarFrosted, + onChanged: settings.setBottomBarFrosted, + ), + ), + if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings), _NavMenuStyleRow(settings: settings), + if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu) + NooSettingsRow( + icon: LucideIcons.userRound, + label: const Text('Avatar position'), + trailing: NooSegmentedControl( + onSurface: true, + value: settings.avatarPosition, + onChanged: settings.setAvatarPosition, + options: const [ + NooSegmentOption(value: AvatarPosition.top, label: 'Top'), + NooSegmentOption( + value: AvatarPosition.bottom, + label: 'Bottom', + ), + ], + ), + ), NooSettingsRow( icon: LucideIcons.search, label: const Text('Search in bottom bar'), @@ -83,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget { icon: LucideIcons.palette, label: const Text('Accent color'), subtitle: Text( - settings.useDynamicColor + settings.useDynamicColor && + Theme.of(context).platform != TargetPlatform.iOS ? 'Matching your wallpaper' : settings.seedColor == AppTheme.defaultAccent ? 'Default' : 'Custom color', ), trailing: _AccentSwatchDot( - color: settings.useDynamicColor ? null : settings.seedColor, + color: + settings.useDynamicColor && + Theme.of(context).platform != TargetPlatform.iOS + ? null + : settings.seedColor, ), onTap: () => _openAccentPicker(context, settings), ), @@ -103,15 +138,16 @@ class SettingsAppearanceSection extends StatelessWidget { onChanged: settings.setAmoledDark, ), ), - NooSettingsRow( - icon: LucideIcons.waves, - label: const Text('Seek bar style'), - subtitle: const Text( - 'The progress bar style used when playing videos', + if (Theme.of(context).platform != TargetPlatform.iOS) + NooSettingsRow( + icon: LucideIcons.waves, + label: const Text('Seek bar style'), + subtitle: const Text( + 'The progress bar style used when playing videos', + ), + value: _seekBarStyleLabel(settings.mediaProgressBarStyle), + onTap: () => _openSeekBarPicker(context, settings), ), - value: _seekBarStyleLabel(settings.mediaProgressBarStyle), - onTap: () => _openSeekBarPicker(context, settings), - ), ], ), ], @@ -155,6 +191,7 @@ class _ThemeRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.themeMode, onChanged: settings.setThemeMode, options: const [ @@ -218,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.bottomBarStyle, onChanged: settings.setBottomBarStyle, options: const [ @@ -240,6 +278,118 @@ class _BottomBarStyleRow extends StatelessWidget { } } +/// How strong the frosted-glass bottom bar looks: three presets for a quick +/// pick, and the blur / opacity sliders they set, for anything in between. +/// Only shown while frosted glass is on. +class _FrostedGlassRow extends StatelessWidget { + final SettingsController settings; + + const _FrostedGlassRow({required this.settings}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.sparkles, size: 20, color: colors.fg2), + const SizedBox(width: 14), + Text( + 'Frost strength', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + ], + ), + const SizedBox(height: 12), + NooSegmentedControl( + fill: true, + onSurface: true, + // No segment selected once the sliders no longer match a preset. + value: settings.frostedPreset, + onChanged: (preset) { + if (preset != null) settings.setFrostedPreset(preset); + }, + options: [ + for (final preset in FrostedGlassPreset.values) + NooSegmentOption( + value: preset, + label: preset.label, + ), + ], + ), + const SizedBox(height: 8), + _SliderLine( + label: 'Blur', + readout: settings.bottomBarFrostedBlur.round().toString(), + slider: NooSlider( + value: settings.bottomBarFrostedBlur, + min: minFrostedBlur, + max: maxFrostedBlur, + semanticLabel: 'Blur', + onChanged: settings.setFrostedBlur, + ), + ), + _SliderLine( + label: 'Opacity', + readout: '${(settings.bottomBarFrostedOpacity * 100).round()}%', + slider: NooSlider( + value: settings.bottomBarFrostedOpacity, + min: minFrostedOpacity, + max: maxFrostedOpacity, + semanticLabel: 'Opacity', + onChanged: settings.setFrostedOpacity, + ), + ), + ], + ), + ), + ); + } +} + +class _SliderLine extends StatelessWidget { + final String label; + final String readout; + final Widget slider; + + const _SliderLine({ + required this.label, + required this.readout, + required this.slider, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Row( + children: [ + SizedBox( + width: 64, + child: Text(label, style: NooText.body.copyWith(color: colors.fg2)), + ), + Expanded(child: slider), + SizedBox( + width: 44, + child: Text( + readout, + textAlign: TextAlign.end, + style: NooText.body.copyWith(color: colors.fg3), + ), + ), + ], + ); + } +} + /// "Navigation menu" (Settings → Appearance): which widget opens hidden /// tabs + Settings - the original `menu` icon that opens a left `Drawer`, /// or the avatar button instead (opens `showAvatarMenu`, a dropdown @@ -278,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.navMenuStyle, onChanged: settings.setNavMenuStyle, options: const [ @@ -331,6 +482,7 @@ class _FabStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.fabStyle, onChanged: settings.setFabStyle, options: const [ @@ -384,22 +536,26 @@ class _AccentSwatchDot extends StatelessWidget { } void _openAccentPicker(BuildContext context, SettingsController settings) { + final useDynamicColor = + settings.useDynamicColor && + Theme.of(context).platform != TargetPlatform.iOS; Widget grid(NooColors colors) => Wrap( spacing: 14, runSpacing: 14, children: [ - _AccentSwatch( - isSelected: settings.useDynamicColor, - onTap: () { - settings.setUseDynamicColor(true); - Navigator.pop(context); - }, - background: colors.surface3, - child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20), - ), + if (Theme.of(context).platform != TargetPlatform.iOS) + _AccentSwatch( + isSelected: settings.useDynamicColor, + onTap: () { + settings.setUseDynamicColor(true); + Navigator.pop(context); + }, + background: colors.surface3, + child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20), + ), for (final color in AppTheme.seedColors) _AccentSwatch( - isSelected: !settings.useDynamicColor && settings.seedColor == color, + isSelected: !useDynamicColor && settings.seedColor == color, onTap: () { settings.setSeedColor(color); Navigator.pop(context); diff --git a/lib/widgets/settings/settings_files_cache.dart b/lib/widgets/settings/settings_files_cache.dart index 41c40e9..825ab19 100644 --- a/lib/widgets/settings/settings_files_cache.dart +++ b/lib/widgets/settings/settings_files_cache.dart @@ -125,12 +125,16 @@ class _CacheIntervalRow extends StatelessWidget { onChanged: (value) => onChanged(value.round()), ), ), - if (minutes < 15) + if (Theme.of(context).platform == TargetPlatform.iOS || + (Theme.of(context).platform == TargetPlatform.android && minutes < 15)) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( - 'While the app is closed, synced files refresh at most every ' - '15 min (an Android limit).', + Theme.of(context).platform == TargetPlatform.iOS + ? 'iOS decides when background sync runs; the refresh ' + 'interval is a request, not a guaranteed schedule.' + : 'While the app is closed, synced files refresh at most every ' + '15 min (an Android limit).', style: NooText.meta.copyWith(color: colors.fg3), ), ), diff --git a/lib/widgets/settings/settings_security.dart b/lib/widgets/settings/settings_security.dart index 341ab93..02865e8 100644 --- a/lib/widgets/settings/settings_security.dart +++ b/lib/widgets/settings/settings_security.dart @@ -6,46 +6,28 @@ import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; import 'settings_section.dart'; -/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's -/// "Biometric label" row, both the mobile table and the macOS/Windows -/// desktop table). Falls back to generic copy on platforms the spec -/// doesn't name (Linux, tests). -String biometricLabel(TargetPlatform platform) { - switch (platform) { - case TargetPlatform.iOS: - return 'Lock with Face ID'; - case TargetPlatform.android: - return 'Lock with fingerprint'; - case TargetPlatform.macOS: - return 'Touch ID'; - case TargetPlatform.windows: - return 'Windows Hello'; - case TargetPlatform.linux: - case TargetPlatform.fuchsia: - return 'Lock with device credential'; - } -} - -/// Settings section 3: login lock, which gates opening the app, switching -/// accounts, and revealing hidden files behind the device's own PIN/ +/// Settings section 3: three independent locks - opening the app, switching +/// accounts, and revealing hidden files - each behind the device's own PIN/ /// biometric credential (see `AppLockService` - this app never stores or -/// handles a PIN itself). +/// handles a PIN itself). Turning one on or off asks for that credential, and +/// none of them requires or implies another. class SettingsSecuritySection extends StatelessWidget { const SettingsSecuritySection({super.key}); - Future _handleLoginLockChanged( - BuildContext context, - SessionController session, - bool value, - ) async { - final success = value ? await session.setupLoginLock() : await session.disableLoginLock(); + Future _handleLockChanged( + BuildContext context, { + required Future Function() change, + required bool turningOn, + required String name, + }) async { + final success = await change(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - value - ? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured" - : 'Could not turn off login lock', + turningOn + ? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured" + : 'Could not turn off $name', ), behavior: SnackBarBehavior.floating, ), @@ -56,18 +38,24 @@ class SettingsSecuritySection extends StatelessWidget { @override Widget build(BuildContext context) { final session = context.watch(); - final platform = Theme.of(context).platform; return SettingsSection( title: 'Security', children: [ NooSettingsRow( icon: LucideIcons.lock, - label: Text(biometricLabel(platform)), - subtitle: const Text("Require this device's PIN or biometric to open Noo"), + label: const Text('App lock'), + subtitle: const Text( + "Require this device's PIN or biometric to open Noo", + ), trailing: NooToggle( checked: session.loginLockEnabled, - onChanged: (value) => _handleLoginLockChanged(context, session, value), + onChanged: (value) => _handleLockChanged( + context, + change: value ? session.setupLoginLock : session.disableLoginLock, + turningOn: value, + name: 'login lock', + ), ), ), NooSettingsRow( @@ -76,7 +64,12 @@ class SettingsSecuritySection extends StatelessWidget { subtitle: const Text('Unlock to switch between saved accounts'), trailing: NooToggle( checked: session.lockAccountSwitching, - onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null, + onChanged: (value) => _handleLockChanged( + context, + change: () => session.setLockAccountSwitching(value), + turningOn: value, + name: 'account switching lock', + ), ), ), NooSettingsRow( @@ -85,7 +78,12 @@ class SettingsSecuritySection extends StatelessWidget { subtitle: const Text('Unlock to reveal hidden files and folders'), trailing: NooToggle( checked: session.lockHiddenFiles, - onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null, + onChanged: (value) => _handleLockChanged( + context, + change: () => session.setLockHiddenFiles(value), + turningOn: value, + name: 'hidden files lock', + ), ), ), ], diff --git a/lib/widgets/settings/settings_swipe.dart b/lib/widgets/settings/settings_swipe.dart index 0165994..eb01b8c 100644 --- a/lib/widgets/settings/settings_swipe.dart +++ b/lib/widgets/settings/settings_swipe.dart @@ -6,19 +6,6 @@ import '../../theme/design_tokens.dart'; import '../noo/lists/noo_settings_row.dart'; import 'settings_section.dart'; -String _swipeActionLabel(SwipeAction action) { - switch (action) { - case SwipeAction.none: - return 'None'; - case SwipeAction.favorite: - return 'Favorite'; - case SwipeAction.delete: - return 'Delete'; - case SwipeAction.share: - return 'Share'; - } -} - /// Settings section 8: what swiping a file left/right does in list view. class SettingsSwipeSection extends StatelessWidget { const SettingsSwipeSection({super.key}); @@ -35,7 +22,8 @@ class SettingsSwipeSection extends StatelessWidget { options: [ for (final action in SwipeAction.values) NooSettingsRow( - label: Text(_swipeActionLabel(action)), + icon: action.selectionKind?.icon, + label: Text(action.label), trailing: action == current ? Icon( LucideIcons.check, @@ -64,7 +52,7 @@ class SettingsSwipeSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.chevronsRight, label: const Text('Swipe right'), - value: _swipeActionLabel(settings.swipeRightAction), + value: settings.swipeRightAction.label, onTap: () => _openPicker( context, title: 'Swipe right', @@ -75,7 +63,7 @@ class SettingsSwipeSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.chevronsLeft, label: const Text('Swipe left'), - value: _swipeActionLabel(settings.swipeLeftAction), + value: settings.swipeLeftAction.label, onTap: () => _openPicker( context, title: 'Swipe left', diff --git a/lib/widgets/sticky_header_delegate.dart b/lib/widgets/sticky_header_delegate.dart index 342386e..61ce26f 100644 --- a/lib/widgets/sticky_header_delegate.dart +++ b/lib/widgets/sticky_header_delegate.dart @@ -10,8 +10,13 @@ import '../theme/design_tokens.dart'; class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { final double height; final Widget child; + final bool floating; - const StickyHeaderDelegate({required this.height, required this.child}); + const StickyHeaderDelegate({ + required this.height, + required this.child, + this.floating = false, + }); @override double get minExtent => height; @@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { // Colors.transparent` guards against the same M3 elevation-tint // behavior even if this ever gets a non-zero elevation. return Material( - color: context.nooColors.bg, + color: floating ? Colors.transparent : context.nooColors.bg, surfaceTintColor: Colors.transparent, child: child, ); @@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { @override bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) { - return oldDelegate.height != height || oldDelegate.child != child; + return oldDelegate.height != height || + oldDelegate.child != child || + oldDelegate.floating != floating; } } diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index d1a4e89..957f8b0 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -97,19 +97,26 @@ Widget tabEmptySliver( /// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so /// Scaffold already shrinks the body above them - this is then pure /// breathing room, not overlap prevention. [NooBottomBarStyle.floating] -/// bars draw over an extended body instead, so nothing reserves space for +/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an +/// extended body instead, so nothing reserves space for /// them automatically: the clearance has to cover the bar's own footprint /// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus /// the safe-area inset below it, or the last row ends up hidden under it. double bottomBarClearance(BuildContext context) { final settings = context.watch(); - if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100; + final barStyle = settings.bottomBarStyle; + if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) { + return 100; + } final barHeight = NooBottomBar.rowHeight( NooLayout.navStyle(context), - NooBottomBarStyle.floating, + barStyle, ); + final margin = barStyle == NooBottomBarStyle.floating + ? NooBottomBar.floatingBottomMargin + : 0; final safeBottom = MediaQuery.paddingOf(context).bottom; - return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24; + return barHeight + margin + safeBottom + 24; } /// Bottom padding sliver so the last row/card clears the bottom nav/FAB - diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index 7c30c0c..19cf736 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart'; import '../frosted_glass_container.dart'; import 'viewer_icon_button.dart'; -/// The bottom panel overlaid on the media viewer: an optional transport row -/// ([transportControls], video only) above the action row -/// (share/favorite/open/download/delete/details) - one continuous flush, -/// full-width `FrostedGlassContainer` panel, not two separate floating -/// pills. Kept on that blurred chrome as a deliberate exception to the -/// design system's flat product UI (see `file_viewer_screen.dart`'s -/// build() comment for why), unlike the now-flat bottom nav bar -/// (`bottom_nav_bar.dart`) this screen sits above. +/// A full-width media panel containing transport, actions and details. +/// Its solid/frosted surface follows the shared appearance preference. class MediaActionBar extends StatelessWidget { final bool isFavorite; final bool isBusy; diff --git a/lib/widgets/viewer/media_text_preview.dart b/lib/widgets/viewer/media_text_preview.dart index 8dde92d..7d280df 100644 --- a/lib/widgets/viewer/media_text_preview.dart +++ b/lib/widgets/viewer/media_text_preview.dart @@ -2,11 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../models/nextcloud_item.dart'; import '../../providers/session_controller.dart'; import '../../theme/design_tokens.dart'; -import '../noo/core/noo_button.dart'; /// Space the viewer's floating top bar and bottom action bar cover, so the /// text sits clear of them (and the status bar) instead of scrolling @@ -14,16 +12,97 @@ import '../noo/core/noo_button.dart'; const double _kTopBarClearance = 72; const double _kActionBarClearance = 112; +/// What the viewer's top bar needs from [MediaTextPreview]: whether to show +/// the Edit/Preview toggle and the Save button, their current state, and a way +/// to trigger them. The preview owns the text, the editing mode and the save; +/// this just mirrors that out so `FileViewerScreen` can draw the buttons in +/// its own top bar (they used to float inside the preview). +class TextPreviewController extends ChangeNotifier { + bool _canToggleEditing = false; + bool _editing = false; + bool _dirty = false; + bool _saving = false; + bool _disposed = false; + VoidCallback? _onToggleEditing; + Future Function()? _onSave; + + /// Markdown, writable (not the read-only Offline copy) and loaded - the + /// Edit/Preview toggle only exists then. + bool get canToggleEditing => _canToggleEditing; + + /// Markdown is currently showing its raw text (the toggle then offers + /// "Preview"); false while rendered (it offers "Edit"). + bool get editing => _editing; + + /// The text differs from what's saved on the server. + bool get dirty => _dirty; + bool get saving => _saving; + + /// The Save button is shown once the content has changed, and stays (as a + /// spinner) while the save is in flight. + bool get showSave => _dirty || _saving; + + void toggleEditing() => _onToggleEditing?.call(); + Future save() async => _onSave?.call(); + + void _bind(VoidCallback toggleEditing, Future Function() save) { + _onToggleEditing = toggleEditing; + _onSave = save; + } + + /// The preview went away: nothing to toggle or save any more. Quiet (no + /// notification) - it happens while the tree is being torn down. + void _unbind() { + _onToggleEditing = null; + _onSave = null; + _canToggleEditing = false; + _editing = false; + _dirty = false; + _saving = false; + } + + void _update({ + required bool canToggleEditing, + required bool editing, + required bool dirty, + required bool saving, + }) { + if (_canToggleEditing == canToggleEditing && + _editing == editing && + _dirty == dirty && + _saving == saving) { + return; + } + _canToggleEditing = canToggleEditing; + _editing = editing; + _dirty = dirty; + _saving = saving; + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + /// Text/markdown file preview and editor for [FileViewerScreen]'s static /// (non-swipeable) path (`_textPreviewExtensions`). Content is always -/// editable when online; a Save button appears once it has changed. Markdown -/// files open rendered, with an Edit/Preview toggle. Opened -/// from the Offline tab ([localPathResolver] set) it is read-only, since the -/// local copy has no server to write back to. +/// editable when online - after tapping Edit; every file opens read-only +/// (Markdown rendered) with an Edit/Preview toggle - and Save appears once it +/// has changed. Both controls live in the viewer's +/// top bar, driven through [controller]. Opened from the Offline tab +/// ([localPathResolver] set) it is read-only, since the local copy has no +/// server to write back to. class MediaTextPreview extends StatefulWidget { final NextcloudItem item; final SessionController session; + /// Receives this preview's editing/save state and is how the top bar's + /// buttons reach it. Optional so the preview also works on its own. + final TextPreviewController? controller; + /// When set, read bytes from the local path it resolves to instead of /// fetching from the server - see /// `FileViewerScreen.localPathResolver`'s doc comment. @@ -33,6 +112,7 @@ class MediaTextPreview extends StatefulWidget { super.key, required this.item, required this.session, + this.controller, this.localPathResolver, }); @@ -46,12 +126,15 @@ class _MediaTextPreviewState extends State { String? _error; bool _isSaving = false; bool _editing = false; + final _focus = FocusNode(); bool get _isMarkdown { final name = widget.item.name.toLowerCase(); return name.endsWith('.md') || name.endsWith('.markdown'); } + /// Markdown shows rendered until the user taps Edit. Plain text has no + /// rendered form, so it just shows its (read-only) text until then. bool get _showRendered => _isMarkdown && !_editing; bool get _readOnly => widget.localPathResolver != null; @@ -60,16 +143,45 @@ class _MediaTextPreviewState extends State { @override void initState() { super.initState(); - _controller.addListener(() => setState(() {})); + widget.controller?._bind(_toggleEditing, _save); + _controller.addListener(() { + setState(() {}); + _syncToolbar(); + }); _load(); } @override void dispose() { + widget.controller?._unbind(); + _focus.dispose(); _controller.dispose(); super.dispose(); } + /// Mirrors the editing/save state out to the viewer's top bar. Only called + /// from event handlers and async completions - never from build or + /// initState, where notifying the bar's listeners would be illegal. + void _syncToolbar() { + widget.controller?._update( + canToggleEditing: _saved != null && !_readOnly, + editing: _editing, + dirty: _dirty, + saving: _isSaving, + ); + } + + void _toggleEditing() { + setState(() => _editing = !_editing); + _syncToolbar(); + // Tapping Edit should put the cursor in the text, not just unlock it. + if (_editing) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focus.requestFocus(), + ); + } + } + Future _load() async { try { final List bytes; @@ -89,6 +201,7 @@ class _MediaTextPreviewState extends State { final text = utf8.decode(bytes, allowMalformed: true); _controller.text = text; setState(() => _saved = text); + _syncToolbar(); } } catch (e) { if (mounted) setState(() => _error = e.toString()); @@ -99,6 +212,7 @@ class _MediaTextPreviewState extends State { final messenger = ScaffoldMessenger.of(context); final text = _controller.text; setState(() => _isSaving = true); + _syncToolbar(); var ok = false; try { ok = await widget.session.service!.putBytes( @@ -111,6 +225,7 @@ class _MediaTextPreviewState extends State { _isSaving = false; if (ok) _saved = text; }); + _syncToolbar(); messenger.showSnackBar( SnackBar( content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'), @@ -158,7 +273,10 @@ class _MediaTextPreviewState extends State { padding: padding, child: TextField( controller: _controller, - readOnly: _readOnly, + focusNode: _focus, + // Read-only until Edit is tapped (always, for the + // Offline copy). + readOnly: _readOnly || !_editing, maxLines: null, keyboardType: TextInputType.multiline, style: style, @@ -171,30 +289,6 @@ class _MediaTextPreviewState extends State { ), ), ), - if (_isMarkdown && !_readOnly) - Positioned( - right: 20, - top: safe.top + _kTopBarClearance - 8, - child: NooButton( - variant: NooButtonVariant.tonal, - size: NooButtonSize.compact, - icon: _editing ? LucideIcons.eye : LucideIcons.pencil, - onTap: () => setState(() => _editing = !_editing), - child: Text(_editing ? 'Preview' : 'Edit'), - ), - ), - if (_dirty) - Positioned( - right: 20, - bottom: safe.bottom + _kActionBarClearance, - child: NooButton( - size: NooButtonSize.card, - icon: LucideIcons.save, - disabled: _isSaving, - onTap: _isSaving ? null : _save, - child: Text(_isSaving ? 'Saving…' : 'Save'), - ), - ), ], ), ); diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart index 879bd97..7644d24 100644 --- a/lib/widgets/viewer/media_video_preview.dart +++ b/lib/widgets/viewer/media_video_preview.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; @@ -198,12 +199,14 @@ class VideoTransportControls extends StatelessWidget { ), Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: WavySeekBar( - controller: controller, - playedColor: fg, - trackColor: fg.withValues(alpha: 0.3), - style: progressBarStyle, - ), + child: Theme.of(context).platform == TargetPlatform.iOS + ? IosSeekBar(controller: controller, color: fg) + : WavySeekBar( + controller: controller, + playedColor: fg, + trackColor: fg.withValues(alpha: 0.3), + style: progressBarStyle, + ), ), ], ), @@ -213,6 +216,60 @@ class VideoTransportControls extends StatelessWidget { } } +class IosSeekBar extends StatefulWidget { + final VideoPlayerController controller; + final Color color; + const IosSeekBar({super.key, required this.controller, required this.color}); + + @override + State createState() => _IosSeekBarState(); +} + +class _IosSeekBarState extends State { + double? _dragRatio; + bool _resumeAfterScrubbing = false; + + @override + Widget build(BuildContext context) { + final value = widget.controller.value; + final durationMs = value.duration.inMilliseconds; + final ratio = + (_dragRatio ?? + (durationMs > 0 + ? value.position.inMilliseconds / durationMs + : 0.0)) + .clamp(0.0, 1.0); + return SizedBox( + width: double.infinity, + child: CupertinoSlider( + value: ratio, + activeColor: widget.color, + thumbColor: CupertinoColors.white, + onChangeStart: durationMs <= 0 + ? null + : (_) { + _resumeAfterScrubbing = value.isPlaying; + if (_resumeAfterScrubbing) widget.controller.pause(); + }, + onChanged: durationMs <= 0 + ? null + : (ratio) { + setState(() => _dragRatio = ratio); + widget.controller.seekTo(value.duration * ratio); + }, + onChangeEnd: durationMs <= 0 + ? null + : (ratio) async { + await widget.controller.seekTo(value.duration * ratio); + if (!mounted) return; + setState(() => _dragRatio = null); + if (_resumeAfterScrubbing) widget.controller.play(); + }, + ), + ); + } +} + /// A Material You "expressive" wavy seek bar: the played portion of the /// track animates as a travelling sine wave while playing and settles flat /// when paused, matching the Android 16+ media player style. Drag or tap diff --git a/test/providers/files_filters_test.dart b/test/providers/files_filters_test.dart new file mode 100644 index 0000000..060c3ce --- /dev/null +++ b/test/providers/files_filters_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/nextcloud_item.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/providers/session_controller.dart'; + +NextcloudItem _item(String path, {String? mountType}) => NextcloudItem( + id: path, + name: path.split('/').last, + path: path, + type: NextcloudItemType.file, + size: 1, + lastModified: DateTime(2026), + mountType: mountType, +); + +/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and +/// how a pref saved before the three-way hidden filter is read back. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => call.method == 'readAll' ? {} : null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async => call.method == 'check' ? ['none'] : null, + ); + messenger.setMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/connectivity_status'), + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + final items = [ + _item('/a.txt'), + _item('/.secret'), + _item('/docs/.git/config'), + _item('/nas', mountType: 'external'), + _item('/.nas-hidden', mountType: 'external'), + ]; + List paths(List l) => l.map((i) => i.path).toList(); + + FilesController build() => + FilesController(SessionController(ConnectivityController())); + + test('hidden filter: hide / only / include', () { + final files = build()..setStorageScope(StorageScope.all); + List run(HiddenFilesFilter h) => paths( + files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h), + ); + expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']); + expect(run(HiddenFilesFilter.only), [ + '/.secret', + '/docs/.git/config', + '/.nas-hidden', + ]); + expect(run(HiddenFilesFilter.include), paths(items)); + }); + + test('storage scope: cloud / external / all', () { + final files = build(); + List run(StorageScope s) { + files.setStorageScope(s); + return paths( + files.applyCommonFilters( + items, + showFavoritesOnly: false, + hidden: HiddenFilesFilter.include, + ), + ); + } + + expect(run(StorageScope.cloud), [ + '/a.txt', + '/.secret', + '/docs/.git/config', + ]); + expect(run(StorageScope.external), ['/nas', '/.nas-hidden']); + expect(run(StorageScope.all), paths(items)); + }); + + test('hidden + external combine', () { + final files = build()..setStorageScope(StorageScope.external); + expect( + paths( + files.applyCommonFilters( + items, + showFavoritesOnly: false, + hidden: HiddenFilesFilter.only, + ), + ), + ['/.nas-hidden'], + ); + }); + + group('loadHiddenFilter', () { + Future load(Map values) async { + SharedPreferences.setMockInitialValues(values); + return FilesController.loadHiddenFilter( + await SharedPreferences.getInstance(), + 'new', + 'old', + ); + } + + test('defaults to hide', () async { + expect(await load({}), HiddenFilesFilter.hide); + }); + + test('the old show-hidden bool maps to hide / include', () async { + expect(await load({'old': true}), HiddenFilesFilter.include); + expect(await load({'old': false}), HiddenFilesFilter.hide); + }); + + test('the new key wins and an unknown value falls back to hide', () async { + expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only); + expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide); + }); + }); +} diff --git a/test/providers/security_locks_test.dart b/test/providers/security_locks_test.dart new file mode 100644 index 0000000..be09cab --- /dev/null +++ b/test/providers/security_locks_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/services/app_lock_service.dart'; + +/// The three Settings -> Security locks (open the app, switch accounts, reveal +/// hidden files) each work on their own - none needs or implies another. The +/// device prompt is replaced with a recorder so the gates can be exercised. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => call.method == 'readAll' ? {} : null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async => call.method == 'check' ? ['none'] : null, + ); + messenger.setMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/connectivity_status'), + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + late List prompts; + var authSucceeds = true; + var deviceSupported = true; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + prompts = []; + authSucceeds = true; + deviceSupported = true; + AppLockService.debugAuthenticate = (reason) async { + prompts.add(reason); + return authSucceeds; + }; + AppLockService.debugIsDeviceSupported = () async => deviceSupported; + }); + + tearDown(() { + AppLockService.debugAuthenticate = null; + AppLockService.debugIsDeviceSupported = null; + }); + + SessionController build() => SessionController(ConnectivityController()); + + test( + 'hidden files and account switching can be locked without login lock', + () async { + final session = build(); + + expect(await session.setLockHiddenFiles(true), isTrue); + expect(await session.setLockAccountSwitching(true), isTrue); + + expect(session.lockHiddenFiles, isTrue); + expect(session.lockAccountSwitching, isTrue); + expect(session.loginLockEnabled, isFalse); + expect( + session.needsUnlock, + isFalse, + reason: 'opening the app stays open', + ); + }, + ); + + test('a gate prompts on its own, with login lock off', () async { + final session = build(); + + expect(await session.passGate(false, 'unused'), isTrue); + expect(prompts, isEmpty, reason: 'a gate that is off never prompts'); + + expect(await session.passGate(true, 'Unlock to show hidden files'), isTrue); + expect(prompts, ['Unlock to show hidden files']); + + authSucceeds = false; + expect(await session.passGate(true, 'again'), isFalse); + }); + + test( + 'turning a lock on needs a capable device and a successful auth', + () async { + final session = build(); + + deviceSupported = false; + expect(await session.setLockHiddenFiles(true), isFalse); + expect(session.lockHiddenFiles, isFalse); + + deviceSupported = true; + authSucceeds = false; + expect(await session.setLockHiddenFiles(true), isFalse); + expect(session.lockHiddenFiles, isFalse); + + authSucceeds = true; + expect(await session.setLockHiddenFiles(true), isTrue); + expect(session.lockHiddenFiles, isTrue); + }, + ); + + test( + 'turning a lock off needs auth, so it cannot just be flipped away', + () async { + final session = build(); + await session.setLockAccountSwitching(true); + prompts.clear(); + + authSucceeds = false; + expect(await session.setLockAccountSwitching(false), isFalse); + expect(session.lockAccountSwitching, isTrue); + + authSucceeds = true; + expect(await session.setLockAccountSwitching(false), isTrue); + expect(session.lockAccountSwitching, isFalse); + expect(prompts, hasLength(2)); + }, + ); + + test('setting a lock to its current value does not prompt', () async { + final session = build(); + expect(await session.setLockHiddenFiles(false), isTrue); + expect(prompts, isEmpty); + }); + + test('turning login lock off leaves the other two locks alone', () async { + final session = build(); + await session.setupLoginLock(); + await session.setLockAccountSwitching(true); + await session.setLockHiddenFiles(true); + expect(session.loginLockEnabled, isTrue); + + expect(await session.disableLoginLock(), isTrue); + + expect(session.loginLockEnabled, isFalse); + expect(session.lockAccountSwitching, isTrue); + expect(session.lockHiddenFiles, isTrue); + }); + + test('each lock is saved on its own and restored', () async { + final first = build(); + await first.setLockHiddenFiles(true); + // Let the fire-and-forget pref write land. + await Future.delayed(const Duration(milliseconds: 50)); + + final second = build(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(second.lockHiddenFiles, isTrue); + expect(second.lockAccountSwitching, isFalse); + expect(second.loginLockEnabled, isFalse); + }); +} diff --git a/test/providers/settings_frosted_test.dart b/test/providers/settings_frosted_test.dart new file mode 100644 index 0000000..c41e82a --- /dev/null +++ b/test/providers/settings_frosted_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/settings_controller.dart'; + +/// The frosted-glass bottom bar's blur/opacity settings and their presets. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future build() async { + final settings = SettingsController(); + // `_load` is async and not exposed; one event-loop turn lets it finish. + await Future.delayed(Duration.zero); + return settings; + } + + test('starts on the Default preset', () async { + final settings = await build(); + expect(settings.frostedPreset, FrostedGlassPreset.standard); + expect(settings.bottomBarFrostedBlur, 20); + expect(settings.bottomBarFrostedOpacity, 0.72); + }); + + test('presets set both values and are recognised again', () async { + final settings = await build(); + for (final preset in FrostedGlassPreset.values) { + settings.setFrostedPreset(preset); + expect(settings.bottomBarFrostedBlur, preset.blur); + expect(settings.bottomBarFrostedOpacity, preset.opacity); + expect(settings.frostedPreset, preset); + } + }); + + test('presets run from lighter to stronger frosting', () { + expect( + FrostedGlassPreset.less.blur, + lessThan(FrostedGlassPreset.standard.blur), + ); + expect( + FrostedGlassPreset.standard.blur, + lessThan(FrostedGlassPreset.more.blur), + ); + expect( + FrostedGlassPreset.less.opacity, + greaterThan(FrostedGlassPreset.standard.opacity), + ); + expect( + FrostedGlassPreset.standard.opacity, + greaterThan(FrostedGlassPreset.more.opacity), + ); + }); + + test('moving a slider off every preset leaves none selected', () async { + final settings = await build(); + settings.setFrostedBlur(27); + expect(settings.frostedPreset, isNull); + settings.setFrostedBlur(FrostedGlassPreset.standard.blur); + expect(settings.frostedPreset, FrostedGlassPreset.standard); + }); + + test('values are clamped to their ranges', () async { + final settings = await build(); + settings + ..setFrostedBlur(500) + ..setFrostedOpacity(0); + expect(settings.bottomBarFrostedBlur, maxFrostedBlur); + expect(settings.bottomBarFrostedOpacity, minFrostedOpacity); + settings + ..setFrostedBlur(-3) + ..setFrostedOpacity(9); + expect(settings.bottomBarFrostedBlur, minFrostedBlur); + expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity); + }); + + test('saved values are restored (and clamped) on the next launch', () async { + SharedPreferences.setMockInitialValues({ + 'ui_bottom_bar_frosted_blur': 32.0, + 'ui_bottom_bar_frosted_opacity': 7.0, + }); + final settings = await build(); + expect(settings.bottomBarFrostedBlur, 32); + expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity); + }); +} diff --git a/test/providers/settings_selection_position_test.dart b/test/providers/settings_selection_position_test.dart new file mode 100644 index 0000000..1e7b429 --- /dev/null +++ b/test/providers/settings_selection_position_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/settings_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'defaults to top and safely handles an unknown saved position', + () async { + for (final saved in [null, 'unknown']) { + SharedPreferences.setMockInitialValues({ + 'ui_selection_bar_position': ?saved, + }); + final settings = SettingsController(); + await Future.delayed(Duration.zero); + expect(settings.selectionBarPosition, SelectionBarPosition.top); + settings.dispose(); + } + }, + ); + + test( + 'bottom position persists and restores across controller creation', + () async { + SharedPreferences.setMockInitialValues({}); + final settings = SettingsController(); + await Future.delayed(Duration.zero); + settings.setSelectionBarPosition(SelectionBarPosition.bottom); + await Future.delayed(Duration.zero); + final restored = SettingsController(); + await Future.delayed(Duration.zero); + expect(restored.selectionBarPosition, SelectionBarPosition.bottom); + settings.dispose(); + restored.dispose(); + }, + ); + test( + 'avatar position defaults to top and restores a saved bottom choice', + () async { + SharedPreferences.setMockInitialValues({}); + final settings = SettingsController(); + await Future.delayed(Duration.zero); + expect(settings.avatarPosition, AvatarPosition.top); + settings.setAvatarPosition(AvatarPosition.bottom); + await Future.delayed(Duration.zero); + final restored = SettingsController(); + await Future.delayed(Duration.zero); + expect(restored.avatarPosition, AvatarPosition.bottom); + settings.dispose(); + restored.dispose(); + }, + ); +} diff --git a/test/services/native_channel_test.dart b/test/services/native_channel_test.dart new file mode 100644 index 0000000..d4f3f6f --- /dev/null +++ b/test/services/native_channel_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/services/native_channel.dart'; +import 'package:noo/services/share_intent_service.dart'; + +/// iOS has no handler for the `dev.ayushya.noo/*` channels yet, so these use +/// channels with nothing registered - the same `MissingPluginException` the +/// simulator showed at startup. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('test/no_native_side'); + const events = EventChannel('test/no_native_side/events'); + + test( + 'invokeIfAvailable returns null when nothing implements the channel', + () { + expect( + invokeIfAvailable(channel, 'anything'), + completion(isNull), + ); + }, + ); + + test('invokeIfAvailable still returns a real result', () async { + const live = MethodChannel('test/live'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(live, (call) async => 'pong'); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(live, null), + ); + expect(await invokeIfAvailable(live, 'ping'), 'pong'); + }); + + test('invokeOrExplain names the feature the user asked for', () { + expect( + invokeOrExplain(channel, 'startUpload', 'Uploading'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + "Uploading isn't available on this platform yet.", + ), + ), + ); + }); + + test('quietEvents swallows the missing-implementation error', () async { + final errors = []; + final values = []; + final done = Completer(); + quietEvents( + events, + (e) => e as int, + ).listen(values.add, onError: errors.add, onDone: done.complete); + // Give the (failing) native `listen` call time to come back. + await Future.delayed(const Duration(milliseconds: 50)); + expect(errors, isEmpty); + expect(values, isEmpty); + }); + + test('ShareIntentService has nothing to report without a native side', () { + expect(ShareIntentService.getInitialShare(), completion(isEmpty)); + }); +} diff --git a/test/services/share_account_service_test.dart b/test/services/share_account_service_test.dart new file mode 100644 index 0000000..1f3c70c --- /dev/null +++ b/test/services/share_account_service_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/services/share_account_service.dart'; + +/// What the iOS Share Extension is given - the contract with +/// `ios/Shared/SharedAccount.swift`'s `SharedAccounts`. +void main() { + const alice = ShareAccountEntry( + id: 'cloud_example_com__alice', + serverUrl: 'https://cloud.example.com', + username: 'alice', + password: 's3cret', + hiddenFilter: 'include', + storageScope: 'all', + ); + const bob = ShareAccountEntry( + id: 'nc_home_lan__bob', + serverUrl: 'https://nc.home.lan:8443/nextcloud', + username: 'bob', + password: 'pw', + hiddenFilter: 'hide', + storageScope: 'cloud', + ); + + Map build({ + List accounts = const [alice, bob], + String? activeId = 'nc_home_lan__bob', + bool login = true, + bool switching = true, + bool hidden = false, + }) => jsonDecode( + ShareAccountService.buildPayload( + accounts: accounts, + activeId: activeId, + loginLockEnabled: login, + lockAccountSwitching: switching, + lockHiddenFiles: hidden, + ), + ); + + test('basic auth header matches what the app sends', () { + expect( + ShareAccountService.basicAuth('alice', 's3cret'), + 'Basic ${base64Encode(utf8.encode('alice:s3cret'))}', + ); + }); + + test('every account is listed with its own header, name and filter', () { + final accounts = (build()['accounts'] as List).cast>(); + expect(accounts.map((a) => a['id']), [alice.id, bob.id]); + expect( + accounts[0]['authHeader'], + ShareAccountService.basicAuth('alice', 's3cret'), + ); + expect(accounts[0]['displayName'], 'alice@cloud.example.com'); + expect(accounts[0]['hiddenFilter'], 'include'); + expect(accounts[0]['storageScope'], 'all'); + // The display name uses the host only - not the port or sub-path. + expect(accounts[1]['displayName'], 'bob@nc.home.lan'); + expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud'); + expect(accounts[1]['hiddenFilter'], 'hide'); + expect(accounts[1]['storageScope'], 'cloud'); + }); + + test('the active account and the lock settings are passed through', () { + final payload = build(login: true, switching: false, hidden: true); + expect(payload['activeId'], 'nc_home_lan__bob'); + expect(payload['loginLockEnabled'], true); + expect(payload['lockAccountSwitching'], false); + expect(payload['lockHiddenFiles'], true); + }); + + test('no active account is a null activeId, not a missing key', () { + expect(build(activeId: null).containsKey('activeId'), true); + expect(build(activeId: null)['activeId'], isNull); + }); +} diff --git a/test/views/file_viewer_screen_test.dart b/test/views/file_viewer_screen_test.dart new file mode 100644 index 0000000..4a47fed --- /dev/null +++ b/test/views/file_viewer_screen_test.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/models/nextcloud_item.dart'; +import 'package:noo/providers/item_operations.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/services/nextcloud_service.dart'; +import 'package:noo/views/file_viewer_screen.dart'; +import 'package:noo/widgets/noo/core/noo_button.dart'; +import 'package:noo/widgets/viewer/media_action_bar.dart'; +import 'package:provider/provider.dart'; + +import '../widgets/noo/noo_test_utils.dart'; + +class _Service extends NextcloudService { + _Service() : super(serverUrl: '', username: '', password: ''); + + final loaded = Completer>(); + Completer? saving; + String? written; + + @override + Future> fetchBytes(String itemPath) => loaded.future; + + @override + Future putBytes(String itemPath, List bytes) { + written = utf8.decode(bytes); + saving = Completer(); + return saving!.future; + } +} + +// These stand-ins avoid starting session restoration and platform channels. +class _Session extends ChangeNotifier implements SessionController { + _Session(this.service); + + @override + final NextcloudService service; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Operations implements ItemOperations { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Finder _button(String tooltip) => find.byTooltip(tooltip); + +/// The [NooButton] behind the top-bar action tagged [tooltip]. +Finder _noo(String tooltip) => + find.descendant(of: _button(tooltip), matching: find.byType(NooButton)); + +void main() { + setUpNooTests(); + + Future<_Service> mount( + WidgetTester tester, + Size size, { + String name = 'notes.md', + bool offline = false, + }) async { + final service = _Service(); + final session = _Session(service); + addTearDown(session.dispose); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: session), + Provider.value(value: _Operations()), + ], + child: MaterialApp( + theme: nooTheme(Brightness.light), + home: FileViewerScreen( + item: NextcloudItem( + id: '1', + name: name, + path: '/$name', + type: NextcloudItemType.file, + size: 12, + lastModified: DateTime(2026), + ), + localPathResolver: offline ? (_) async => null : null, + ), + ), + ), + ); + return service; + } + + for (final width in [390.0, 1200.0]) { + testWidgets('text actions use the top bar at width $width', (tester) async { + final service = await mount(tester, Size(width, 800)); + expect(_button('Edit'), findsNothing); + expect(_button('Save'), findsNothing); + service.loaded.complete(utf8.encode('# Original')); + await tester.pumpAndSettle(); + + void inHeader(String label) { + final row = find + .ancestor(of: _button('Back'), matching: find.byType(Row)) + .first; + expect( + find.descendant(of: row, matching: _button(label)), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(MediaActionBar), + matching: _button(label), + ), + findsNothing, + ); + } + + inHeader('Edit'); + // A lone action is the primary one, and a compact round icon button. + expect( + tester.widget(_noo('Edit')).variant, + NooButtonVariant.primary, + ); + expect(tester.getSize(_noo('Edit')), const Size(36, 36)); + expect(find.byType(Markdown), findsOneWidget); + await tester.tap(_button('Edit')); + await tester.pumpAndSettle(); + inHeader('Preview'); + await tester.enterText(find.byType(TextField), '# Changed'); + await tester.pump(); + inHeader('Save'); + // With two, Save is primary and the toggle steps back to secondary. + expect( + tester.widget(_noo('Save')).variant, + NooButtonVariant.primary, + ); + expect( + tester.widget(_noo('Preview')).variant, + NooButtonVariant.secondary, + ); + await tester.tap(_button('Preview')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Markdown)).data, '# Changed'); + inHeader('Save'); + await tester.tap(_button('Save')); + await tester.pump(); + expect(service.written, '# Changed'); + // While the save is in flight the Save button gives way to a spinner, + // so it can't be tapped twice. + expect(_button('Save'), findsNothing); + expect( + find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == 'Saving', + ), + findsOneWidget, + ); + service.saving!.complete(false); + await tester.pumpAndSettle(); + inHeader('Save'); + expect(find.text('Could not save file'), findsOneWidget); + await tester.tap(_button('Save')); + await tester.pump(); + service.saving!.complete(true); + await tester.pumpAndSettle(); + expect(_button('Save'), findsNothing); + expect(_button('Edit'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets('plain text opens read-only and follows Edit -> Save too', ( + tester, + ) async { + final service = await mount( + tester, + const Size(390, 800), + name: 'notes.txt', + ); + service.loaded.complete(utf8.encode('Original')); + await tester.pumpAndSettle(); + + bool readOnly() => + tester.widget(find.byType(TextField)).readOnly; + expect(readOnly(), isTrue, reason: 'not directly editable'); + expect(_button('Edit'), findsOneWidget); + expect(_button('Save'), findsNothing); + expect( + tester.widget(_noo('Edit')).variant, + NooButtonVariant.primary, + ); + + await tester.tap(_button('Edit')); + await tester.pumpAndSettle(); + expect(readOnly(), isFalse); + expect(_button('Preview'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'Changed'); + await tester.pump(); + expect(_button('Save'), findsOneWidget); + expect( + tester.widget(_noo('Save')).variant, + NooButtonVariant.primary, + ); + + // Preview goes back to the read-only view, keeping the change unsaved. + await tester.tap(_button('Preview')); + await tester.pumpAndSettle(); + expect(readOnly(), isTrue); + expect(find.text('Changed'), findsOneWidget); + expect(_button('Save'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + for (final offline in [false, true]) { + testWidgets('no text actions for offline=$offline unsupported files', ( + tester, + ) async { + await mount( + tester, + const Size(390, 800), + name: offline ? 'notes.md' : 'data.bin', + offline: offline, + ); + await tester.pumpAndSettle(); + expect(_button('Edit'), findsNothing); + expect(_button('Preview'), findsNothing); + expect(_button('Save'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index 2d703fc..41c296b 100644 --- a/test/widgets/avatar_menu_test.dart +++ b/test/widgets/avatar_menu_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,7 +16,12 @@ import 'package:noo/providers/settings_controller.dart'; import 'package:noo/providers/sync_status_controller.dart'; import 'package:noo/providers/trash_controller.dart'; import 'package:noo/theme/app_theme.dart'; +import 'package:noo/theme/design_tokens.dart'; +import 'package:noo/widgets/noo/core/noo_avatar.dart'; +import 'package:noo/widgets/noo/core/noo_button.dart'; import 'package:noo/widgets/app_top_bar.dart'; +import 'package:noo/widgets/avatar_menu.dart'; +import 'package:noo/widgets/bottom_nav_bar.dart'; import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; /// Covers `SettingsController.navMenuStyle`'s two options as wired through @@ -84,7 +90,24 @@ void main() { Future pumpTopBar( WidgetTester tester, { required NooNavMenuStyle navMenuStyle, + bool withHost = false, + AvatarPosition position = AvatarPosition.top, + bool search = false, + bool reducedMotion = false, + NooNavStyle style = NooNavStyle.android, + NooBottomBarStyle barStyle = NooBottomBarStyle.floating, + AppTab tab = AppTab.files, + FabStyle uploadStyle = FabStyle.auto, + bool frosted = false, + int settleFrames = 30, }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('ui_nav_menu_style', navMenuStyle.name); + await prefs.setString('ui_avatar_position', position.name); + await prefs.setString('ui_bottom_bar_style', barStyle.name); + await prefs.setBool('ui_bottom_bar_frosted', frosted); + await prefs.setDouble('ui_bottom_bar_frosted_blur', 19); + await prefs.setDouble('ui_bottom_bar_frosted_opacity', 0.45); await tester.binding.setSurfaceSize(const Size(400, 800)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -108,15 +131,49 @@ void main() { ), ], child: MaterialApp( - theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false), - home: Scaffold( - drawer: const Drawer(child: Text('drawer open')), - appBar: AppTopBar( - style: NooNavStyle.android, - tab: AppTab.files, - navMenuStyle: navMenuStyle, - ), - body: const SizedBox(), + theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false) + .copyWith( + platform: style == NooNavStyle.ios + ? TargetPlatform.iOS + : TargetPlatform.android, + ), + home: Builder( + builder: (context) { + final scaffold = Scaffold( + drawer: const Drawer(child: Text('drawer open')), + appBar: AppTopBar( + style: style, + tab: tab, + uploadButtonStyle: uploadStyle, + navMenuStyle: navMenuStyle, + avatarPosition: position, + searchInBottomBar: search, + ), + body: const AvatarNavigationBody( + child: SizedBox.expand(key: ValueKey('page content')), + ), + bottomNavigationBar: withHost + ? BottomNavBar( + style: style, + barStyle: barStyle, + tabs: const [AppTab.files, AppTab.photos], + selectedIndex: 0, + onDestinationSelected: (_) {}, + avatarInBottomBar: position == AvatarPosition.bottom, + onSearchTap: search ? () {} : null, + ) + : null, + ); + final child = withHost + ? AvatarNavigationHost(child: scaffold) + : scaffold; + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reducedMotion), + child: child, + ); + }, ), ), ), @@ -125,7 +182,7 @@ void main() { // Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`: // the active account's dependent controllers start periodic // timers/futures that would spin it forever. - for (var i = 0; i < 30; i++) { + for (var i = 0; i < settleFrames; i++) { await tester.pump(const Duration(milliseconds: 10)); } } @@ -174,4 +231,443 @@ void main() { expect(find.text('Add account'), findsOneWidget); }, ); + + testWidgets("other accounts' avatars sit flush right in the dropdown", ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'account_migration_v1_done': true, + 'accounts_list': jsonEncode([ + const SavedAccount( + id: accountId, + serverUrl: 'https://server.example.com', + username: 'alice', + ).toJson(), + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ]), + 'active_account_id': accountId, + }); + await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu); + + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + // Expand the account list from the header. + await tester.tap(find.text('alice')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + final row = find.ancestor( + of: find.text('bob'), + matching: find.byType(InkWell), + ); + final avatar = find.descendant(of: row, matching: find.byType(NooAvatar)); + expect(avatar, findsOneWidget); + + // Only the row's own padding between the avatar and the card's edge - + // no extra gutter pushing it in from the right. + expect( + tester.getTopRight(row).dx - tester.getTopRight(avatar).dx, + NooSpace.md, + ); + // The card is as wide as the Files list's rows: inset NooSpace.sm from + // each screen edge, less its 1px border and 1px inner padding per side. + final screenWidth = tester.getSize(find.byType(MaterialApp)).width; + expect(tester.getSize(row).width, screenWidth - 2 * NooSpace.sm - 4); + }); + testWidgets( + 'top avatar expands and pushes content down; closing restores it', + (tester) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + ); + final page = find.byKey(const ValueKey('page content')); + final before = tester.getTopLeft(page).dy; + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + final during = tester.getTopLeft(page).dy; + expect(during, greaterThan(before)); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.getTopLeft(page).dy, greaterThan(during)); + expect(find.text('Settings'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Close menu').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(tester.getTopLeft(page).dy, before); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + for (final search in [false, true]) { + testWidgets( + 'bottom avatar moves into card without shifting content (search=$search)', + (tester) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: search, + ); + final page = find.byKey(const ValueKey('page content')); + final before = tester.getTopLeft(page); + expect(find.byTooltip('Menu'), findsOneWidget); + final origin = tester.getCenter(find.byTooltip('Menu')); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + final moving = tester.getCenter( + find.bySemanticsLabel('Close menu').last, + ); + expect(moving.dy, lessThan(origin.dy)); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.getTopLeft(page), before); + final card = find.byType(AvatarMenuCard); + expect(card, findsOneWidget); + expect(tester.getBottomRight(card).dy, lessThan(origin.dy)); + expect(tester.getSize(card).width, 376); + if (search) expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Close menu').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('reduced motion opens immediately and system back dismisses', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + reducedMotion: true, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + expect(find.text('Settings'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.takeException(), isNull); + }); + for (final position in AvatarPosition.values) { + for (final frosted in [false, true]) { + testWidgets( + '$position popup shares frost toggle and strength ($frosted)', + (tester) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: position, + frosted: frosted, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final card = find.byType(AvatarMenuCard); + final backdrop = find.ancestor( + of: card, + matching: find.byType(BackdropFilter), + ); + expect(backdrop, frosted ? findsOneWidget : findsNothing); + if (frosted) { + expect( + tester.widget(backdrop).filter, + ImageFilter.blur(sigmaX: 19, sigmaY: 19), + ); + final decorations = tester.widgetList( + find.ancestor(of: card, matching: find.byType(DecoratedBox)), + ); + expect( + decorations.any( + (box) => + box.decoration is BoxDecoration && + ((box.decoration as BoxDecoration).color?.a ?? 0) > 0.44 && + ((box.decoration as BoxDecoration).color?.a ?? 0) < 0.46, + ), + isTrue, + ); + final materials = tester.widgetList( + find.descendant(of: card, matching: find.byType(Material)), + ); + expect( + materials.every( + (material) => material.color == Colors.transparent, + ), + isTrue, + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('iOS upload width interpolates while its label fades', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + style: NooNavStyle.ios, + uploadStyle: FabStyle.mini, + ); + final button = find.byType(NooButton); + final collapsedWidth = tester.getSize(button).width; + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + style: NooNavStyle.ios, + uploadStyle: FabStyle.expanded, + settleFrames: 0, + ); + await tester.pump(const Duration(milliseconds: 100)); + final middleWidth = tester.getSize(button).width; + final labelOpacity = tester + .widget( + find + .ancestor(of: find.text('Upload'), matching: find.byType(Opacity)) + .first, + ) + .opacity; + expect(labelOpacity, greaterThan(0)); + expect(labelOpacity, lessThan(1)); + await tester.pump(const Duration(milliseconds: 300)); + final expandedWidth = tester.getSize(button).width; + expect(middleWidth, greaterThan(collapsedWidth)); + expect(middleWidth, lessThan(expandedWidth)); + expect(tester.getSize(button).height, 32); + expect(tester.takeException(), isNull); + }); + + for (final uploadStyle in FabStyle.values) { + for (final tab in [AppTab.files, AppTab.photos, AppTab.favorites]) { + testWidgets('iOS title row uses $uploadStyle upload control on $tab', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + style: NooNavStyle.ios, + tab: tab, + uploadStyle: uploadStyle, + ); + final title = find.descendant( + of: find.byType(AppTopBar), + matching: find.text(tab.label), + ); + final titleY = tester.getCenter(title).dy; + expect( + tester.getCenter(find.byTooltip('Upload')).dy, + closeTo(titleY, 1), + ); + expect(tester.getCenter(find.byTooltip('Menu')).dy, closeTo(titleY, 1)); + final expanded = + uploadStyle == FabStyle.expanded || + (uploadStyle == FabStyle.auto && tab != AppTab.favorites); + expect(find.text('Upload'), expanded ? findsOneWidget : findsNothing); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('iOS attached navigation keeps popup above its rail', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + final accounts = jsonDecode(prefs.getString('accounts_list')!) as List; + accounts.add( + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ); + await prefs.setString('accounts_list', jsonEncode(accounts)); + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + style: NooNavStyle.ios, + barStyle: NooBottomBarStyle.attached, + ); + final origin = tester.getCenter(find.byTooltip('Menu')); + expect( + find.descendant( + of: find.byType(BottomNavBar), + matching: find.text('Files'), + ), + findsOneWidget, + ); + expect(find.text('Photos'), findsNothing); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect( + tester.getBottomLeft(find.byType(AvatarMenuCard)).dy, + origin.dy - 25 - 1, + ); + expect(tester.getSize(find.byType(AvatarMenuCard)).width, 400); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect( + tester.getCenter(find.text('Settings')).dy, + lessThan(tester.getCenter(find.text('alice')).dy), + ); + expect( + tester.getCenter(find.text('alice')).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + expect( + tester.getCenter(find.text('bob')).dy, + lessThan(tester.getCenter(find.text('Add Account')).dy), + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }); + + testWidgets('Android floating popup orders navigation before accounts', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + final accounts = jsonDecode(prefs.getString('accounts_list')!) as List; + accounts.add( + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ); + await prefs.setString('accounts_list', jsonEncode(accounts)); + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + ); + final origin = tester.getCenter(find.byTooltip('Menu')); + expect( + find.descendant( + of: find.byType(BottomNavBar), + matching: find.text('Files'), + ), + findsOneWidget, + ); + expect(find.text('Photos'), findsNothing); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect( + tester.getBottomLeft(find.byType(AvatarMenuCard)).dy, + origin.dy - 32 - 12 - 1, + ); + expect(tester.getSize(find.byType(AvatarMenuCard)).width, 376); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect( + tester.getCenter(find.text('Settings')).dy, + lessThan(tester.getCenter(find.text('alice')).dy), + ); + expect( + tester.getCenter(find.text('alice')).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + expect( + tester.getCenter(find.text('bob')).dy, + lessThan(tester.getCenter(find.text('Add Account')).dy), + ); + final currentAvatar = find + .descendant( + of: find.byType(AvatarMenuCard), + matching: find.byType(NooAvatar), + ) + .first; + expect( + tester.getCenter(currentAvatar).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + await tester.tap(currentAvatar); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }); + + testWidgets('expanding accounts also grows the top content offset', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final before = tester + .getTopLeft(find.byKey(const ValueKey('page content'))) + .dy; + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(find.text('Add Account'), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(const ValueKey('page content'))).dy, + greaterThan(before), + ); + expect(tester.takeException(), isNull); + }); + testWidgets('Search satellite closes the bottom navigation popup', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.bySemanticsLabel('Search')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.takeException(), isNull); + }); } diff --git a/test/widgets/filter_mode_rows_test.dart b/test/widgets/filter_mode_rows_test.dart new file mode 100644 index 0000000..e55fd1b --- /dev/null +++ b/test/widgets/filter_mode_rows_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/widgets/filter_mode_rows.dart'; + +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + + group('HiddenFilesFilterRow', () { + testNooWidgets( + 'only the selected option shows its label, tapping another reports it', + (tester, theme, c) async { + var value = HiddenFilesFilter.hide; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 360, + child: HiddenFilesFilterRow( + value: value, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + theme: theme, + ); + expect(find.text('Hidden files'), findsOneWidget); + expect(find.text('Hide'), findsOneWidget); + expect(find.text('Only hidden'), findsNothing); + + await tester.tap(find.byIcon(LucideIcons.layers)); + await tester.pumpAndSettle(); + expect(value, HiddenFilesFilter.include); + expect(find.text('All'), findsOneWidget); + }, + ); + }); + + group('StorageScopeRow', () { + testNooWidgets('offers cloud only / only external / all', ( + tester, + theme, + c, + ) async { + var value = StorageScope.cloud; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 360, + child: StorageScopeRow( + value: value, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + theme: theme, + ); + expect(find.text('External storage'), findsOneWidget); + expect(find.text('Cloud only'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.hardDrive).last); + await tester.pumpAndSettle(); + expect(value, StorageScope.external); + expect(find.text('Only external'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.layers)); + await tester.pumpAndSettle(); + expect(value, StorageScope.all); + expect(find.text('All'), findsOneWidget); + }); + }); +} diff --git a/test/widgets/frosted_glass_container_test.dart b/test/widgets/frosted_glass_container_test.dart new file mode 100644 index 0000000..6925cbe --- /dev/null +++ b/test/widgets/frosted_glass_container_test.dart @@ -0,0 +1,59 @@ +import 'dart:ui' show ImageFilter; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/widgets/frosted_glass_container.dart'; +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + testNooWidgets('viewer glass follows toggle, blur and opacity', ( + tester, + theme, + colors, + ) async { + SharedPreferences.setMockInitialValues({ + 'ui_bottom_bar_frosted': false, + 'ui_bottom_bar_frosted_blur': 19.0, + 'ui_bottom_bar_frosted_opacity': 0.45, + }); + final settings = SettingsController(); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: const FrostedGlassContainer( + child: SizedBox(width: 200, height: 80), + ), + ), + theme: theme, + ); + await tester.pumpAndSettle(); + expect(find.byType(BackdropFilter), findsNothing); + final fill = find.descendant( + of: find.byType(FrostedGlassContainer), + matching: find.byType(Container), + ); + expect( + (tester.widget(fill).decoration as BoxDecoration).color, + colors.surface, + ); + settings.setBottomBarFrosted(true); + await tester.pump(); + expect( + tester.widget(find.byType(BackdropFilter)).filter, + ImageFilter.blur(sigmaX: 19, sigmaY: 19), + ); + expect( + (tester.widget(fill).decoration as BoxDecoration).color, + colors.surface.withValues(alpha: 0.45), + ); + settings.setBottomBarFrosted(false); + await tester.pump(); + expect(find.byType(BackdropFilter), findsNothing); + await tester.pumpWidget(const SizedBox()); + settings.dispose(); + }); +} diff --git a/test/widgets/ios_seek_bar_test.dart b/test/widgets/ios_seek_bar_test.dart new file mode 100644 index 0000000..ea73ad6 --- /dev/null +++ b/test/widgets/ios_seek_bar_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/viewer/media_video_preview.dart'; +import 'package:video_player/video_player.dart'; +import 'noo/noo_test_utils.dart'; + +class _Controller extends VideoPlayerController { + _Controller() : super.networkUrl(Uri.parse('https://example.com/video.mp4')); + Duration? sought; + @override + Future seekTo(Duration position) async { + sought = position; + value = value.copyWith(position: position); + } + + @override + Future pause() async { + value = value.copyWith(isPlaying: false); + } + + @override + Future play() async { + value = value.copyWith(isPlaying: true); + } +} + +void main() { + setUpNooTests(); + for (final playing in [false, true]) { + testWidgets('iOS scrubbing preserves playing=$playing', (tester) async { + final controller = _Controller(); + controller.value = VideoPlayerValue( + duration: const Duration(seconds: 60), + position: const Duration(seconds: 15), + isInitialized: true, + isPlaying: playing, + ); + await pumpNoo( + tester, + IosSeekBar(controller: controller, color: CupertinoColors.white), + ); + final slider = tester.widget( + find.byType(CupertinoSlider), + ); + expect(slider.value, 0.25); + slider.onChangeStart!(0.25); + expect(controller.value.isPlaying, false); + slider.onChanged!(0.5); + await tester.pump(); + expect(controller.sought, const Duration(seconds: 30)); + slider.onChangeEnd!(0.5); + await tester.pump(); + expect(controller.value.isPlaying, playing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await controller.dispose(); + }); + } + testWidgets('iOS scrubber disables seeking before duration is available', ( + tester, + ) async { + final controller = _Controller(); + await pumpNoo( + tester, + IosSeekBar(controller: controller, color: CupertinoColors.white), + ); + expect( + tester.widget(find.byType(CupertinoSlider)).onChanged, + isNull, + ); + await tester.pumpWidget(const SizedBox()); + await controller.dispose(); + }); +} diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 0bd4af8..504686a 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -10,6 +10,7 @@ import 'package:noo/widgets/noo/core/noo_chip.dart'; import 'package:noo/widgets/noo/core/noo_fab.dart'; import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; import 'package:noo/widgets/noo/core/noo_search_field.dart'; +import 'package:noo/widgets/noo/core/noo_slider.dart'; import 'package:noo/widgets/noo/core/noo_segmented_control.dart'; import 'package:noo/widgets/noo/core/noo_toggle.dart'; import 'package:noo/widgets/noo/nav/noo_nav_style.dart'; @@ -74,6 +75,59 @@ void main() { }); }); + group('NooColors.fromDynamicScheme surfaces', () { + for (final brightness in Brightness.values) { + test('cards are clearly lighter than the page (${brightness.name})', () { + // The OS palette overrides `surface` (what `bg` maps to) but not the + // seeded container roles - mimic that mismatch. + final seeded = ColorScheme.fromSeed( + seedColor: const Color(0xFF3F6B8F), + brightness: brightness, + ); + final os = seeded.copyWith( + surface: brightness == Brightness.light + ? const Color(0xFFF7F9FC) + : const Color(0xFF101417), + ); + final c = NooColors.fromDynamicScheme(os); + // WCAG-style contrast ratio between the card and the page. + final hi = c.surface.computeLuminance(); + final lo = c.bg.computeLuminance(); + expect(hi, greaterThan(lo)); + expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04)); + }); + } + }); + + group('NooSlider', () { + testNooWidgets('dragging reports a value within min..max', ( + tester, + theme, + c, + ) async { + var value = 10.0; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 300, + child: NooSlider( + value: value, + min: 0, + max: 40, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + theme: theme, + ); + await tester.drag(find.byType(Slider), const Offset(150, 0)); + await tester.pump(); + expect(value, greaterThan(10)); + expect(value, lessThanOrEqualTo(40)); + }); + }); + group('NooAvatar', () { testNooWidgets('current user uses accent-soft/accent-text', ( tester, diff --git a/test/widgets/noo/files_swipe_test.dart b/test/widgets/noo/files_swipe_test.dart index 1a86f7f..14c4159 100644 --- a/test/widgets/noo/files_swipe_test.dart +++ b/test/widgets/noo/files_swipe_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:noo/widgets/noo/files/noo_swipe_action.dart'; +import 'package:noo/models/selection_action.dart'; +import 'package:noo/providers/settings_controller.dart'; import 'noo_test_utils.dart'; @@ -8,6 +10,52 @@ void main() { setUpNooTests(); group('NooSwipeAction', () { + test('every action-bar action has a swipe setting', () { + expect( + SwipeAction.values + .map((action) => action.selectionKind) + .whereType() + .toSet(), + SelectionActionKind.values.toSet(), + ); + }); + + for (final kind in SelectionActionKind.values) { + testNooWidgets('a full swipe runs ${kind.label}', ( + tester, + theme, + c, + ) async { + var triggered = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 64, + child: NooSwipeAction( + startAction: NooSwipeActionSpec( + kind: kind, + onTriggered: () => triggered++, + ), + child: const ColoredBox( + color: Colors.white, + child: SizedBox(width: 360, height: 64), + ), + ), + ), + theme: theme, + ); + await tester.drag(find.byType(NooSwipeAction), const Offset(60, 0)); + await tester.pumpAndSettle(); + expect(triggered, 0); + expect(find.text(kind.label), findsOneWidget); + expect(find.byIcon(kind.icon), findsOneWidget); + await tester.drag(find.byType(NooSwipeAction), const Offset(300, 0)); + await tester.pump(); + expect(triggered, 1); + }); + } + testNooWidgets('a moderate swipe opens the action without triggering it', ( tester, theme, diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 9a0ccf5..4018f01 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -25,6 +26,107 @@ void main() { setUpNooTests(); group('NooBottomBar', () { + for (final style in NooNavStyle.values) { + for (final barStyle in NooBottomBarStyle.values) { + testNooWidgets( + '$style/$barStyle: frosted draws a backdrop blur, solid does not', + (tester, theme, c) async { + for (final frosted in [false, true]) { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 120, + child: NooBottomBar( + style: style, + barStyle: barStyle, + frosted: frosted, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () {}, + ), + ), + theme: theme, + ); + expect( + find.byType(BackdropFilter), + frosted ? findsWidgets : findsNothing, + ); + } + }, + ); + } + } + + testNooWidgets('frosted bar uses the given blur sigma and fill opacity', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 120, + child: NooBottomBar( + style: NooNavStyle.android, + frosted: true, + frostedBlur: 31, + frostedOpacity: 0.5, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + expect( + tester.widget(find.byType(BackdropFilter)).filter, + ImageFilter.blur(sigmaX: 31, sigmaY: 31), + ); + expect( + find.byWidgetPredicate( + (w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == + c.surface.withValues(alpha: 0.5), + ), + findsOneWidget, + ); + }); + + test('drawsBehindBody: floating or frosted', () { + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false), + isFalse, + ); + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true), + isTrue, + ); + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false), + isTrue, + ); + }); + + test('floating row height is the Android one on both nav styles', () { + expect( + NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating), + 64, + ); + expect( + NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached), + 50, + ); + }); + testNooWidgets('android: label sits below the icon, not beside it', ( tester, theme, @@ -113,7 +215,7 @@ void main() { expect(opacity.opacity, 0); }); - testNooWidgets('ios: unchanged icon-above-label layout, no indicator', ( + testNooWidgets('ios: only selected tab has a label, no indicator', ( tester, theme, c, @@ -136,6 +238,8 @@ void main() { final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images)); final labelCenter = tester.getCenter(find.text('Photos')); expect(labelCenter.dy, greaterThan(iconCenter.dy)); + expect(find.text('Files'), findsNothing); + expect(find.text('Favorites'), findsNothing); }); testNooWidgets('floating: inset, rounded, bordered - not edge to edge', ( diff --git a/test/widgets/selection_bar_overlay_test.dart b/test/widgets/selection_bar_overlay_test.dart new file mode 100644 index 0000000..0917ea9 --- /dev/null +++ b/test/widgets/selection_bar_overlay_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/selection_bar_overlay.dart'; + +void main() { + for (final extendsBody in [false, true]) { + for (final safeBottom in [0.0, 34.0]) { + testWidgets( + 'action bar clears navigation: extend=$extendsBody, inset=$safeBottom', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(bottom: safeBottom); + addTearDown(tester.view.reset); + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + extendBody: extendsBody, + bottomNavigationBar: SafeArea( + top: false, + child: SizedBox( + key: const ValueKey('navigation'), + height: 64, + ), + ), + body: SelectionBarOverlay( + bar: GestureDetector( + onTap: () => taps++, + child: Container( + key: const ValueKey('actions'), + color: Colors.blue, + ), + ), + child: ListView.builder( + itemCount: 100, + itemBuilder: (_, i) => + SizedBox(height: 48, child: Text('Item $i')), + ), + ), + ), + ), + ); + final actions = find.byKey(const ValueKey('actions')); + final navTop = tester + .getTopLeft(find.byKey(const ValueKey('navigation'))) + .dy; + expect(tester.getBottomLeft(actions).dy, navTop - 8); + final before = tester.getTopLeft(actions); + await tester.drag(find.byType(ListView), const Offset(0, -350)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(actions), before); + await tester.tap(actions); + expect(taps, 1); + }, + ); + } + } +} diff --git a/test/widgets/settings_action_bar_test.dart b/test/widgets/settings_action_bar_test.dart new file mode 100644 index 0000000..e5d1aab --- /dev/null +++ b/test/widgets/settings_action_bar_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/selection_action.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/widgets/noo/lists/noo_selection_bar.dart'; +import 'package:noo/widgets/settings/settings_action_bar.dart'; +import 'package:noo/widgets/settings/settings_swipe.dart'; + +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final desktop in [false, true]) { + testNooWidgets( + 'action preview follows reorder and exposes overflow (${desktop ? 'tablet' : 'phone'})', + (tester, theme, colors) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = desktop + ? const Size(1200, 900) + : const Size(380, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final settings = SettingsController(); + addTearDown(settings.dispose); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: SizedBox( + width: desktop ? 640 : 340, + child: const SingleChildScrollView( + child: SettingsActionBarSection(), + ), + ), + ), + theme: theme, + surfaceSize: desktop ? const Size(1200, 900) : const Size(380, 900), + ); + await tester.pumpAndSettle(); + final bar = find.byType(NooSelectionBar); + expect(bar, findsOneWidget); + expect(tester.widget(bar).isDesktop, desktop); + expect(find.text('Preview'), findsOneWidget); + settings.setSelectionActionOrder([ + SelectionActionKind.details, + ...SelectionActionKind.values.where( + (kind) => kind != SelectionActionKind.details, + ), + ]); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant(of: bar, matching: find.byIcon(LucideIcons.info)), + ); + await tester.pump(); + expect(find.text('Details preview'), findsOneWidget); + await tester.tap( + find.descendant( + of: bar, + matching: desktop + ? find.text('More') + : find.byIcon(LucideIcons.ellipsisVertical), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Sync to device'), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); + } + + testNooWidgets('swipe picker offers every action and saves the choice', ( + tester, + theme, + colors, + ) async { + final settings = SettingsController(); + addTearDown(settings.dispose); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: const SettingsSwipeSection(), + ), + theme: theme, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Swipe right')); + await tester.pumpAndSettle(); + for (final kind in SelectionActionKind.values) { + expect(find.text(kind.label), findsAtLeastNWidgets(1)); + } + await tester.ensureVisible(find.text('Details')); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect(settings.swipeRightAction, SwipeAction.details); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('ui_swipe_right_action'), 'details'); + expect(tester.takeException(), isNull); + }); +}