diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 7a18964..fc3bdf4 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -65,7 +65,8 @@ new provider instance): 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` for motion and dismissal behavior. - **Desktop:** 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/server.md b/.claude/context/server.md index 2b955cf..d777aee 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -1021,10 +1021,10 @@ backup) with state in `<...>/sync-state//*.json`. 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. -- **Deliberate differences from Android:** a file deleted on the server but - edited here is **kept** (untracked) rather than deleted - the edit is the - only copy; 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 +- **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). @@ -1039,12 +1039,19 @@ backup) with state in `<...>/sync-state//*.json`. (`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 - and Wi-Fi-only is + 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). diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 570aba6..4a45074 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -123,7 +123,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) - @@ -142,6 +143,19 @@ 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: @@ -149,7 +163,8 @@ blocks are noted where they matter: cloud (default) / external / all. Files and Photos each persist their own hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old `ui_show_hidden*` bools are still read as a fallback, `true` → include); - `StorageScope` stays shared. Leaving `hide` is still behind the + `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) @@ -248,7 +263,7 @@ 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). `floating` ignores `NooNavStyle` and always uses the Android row (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; @@ -281,44 +296,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. + Attached popup order is 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 @@ -348,11 +369,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 diff --git a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift index 2f06664..26cb52d 100644 --- a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift +++ b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift @@ -320,7 +320,13 @@ final class SyncCoordinator { /// 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 - let gate = OnceGate { task.setTaskCompleted(success: $0) } + 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) } @@ -332,7 +338,7 @@ final class SyncCoordinator { } // The system's time is up: stop the work and the runs it started (each // saves what it has) and report. - task.expirationHandler = { [weak self] in + return { [weak self] in work.cancel() for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) } gate.finish(false) diff --git a/ios/Runner/Native/SyncEngine/SyncDiff.swift b/ios/Runner/Native/SyncEngine/SyncDiff.swift index de4bb60..3ae8621 100644 --- a/ios/Runner/Native/SyncEngine/SyncDiff.swift +++ b/ios/Runner/Native/SyncEngine/SyncDiff.swift @@ -89,16 +89,7 @@ enum SyncDiff { for fileId in priorFolderFileIds where !seen.contains(fileId) { guard let prior = state[fileId] else { continue } - // Deleted on the server. If it was also edited here since the last sync - // that edit is the only copy left - keep it (untracked) instead of - // deleting it along with the rest. - if let local = LocalFS.stat(syncRoot.appendingPathComponent(prior.relPath)), - local.mtimeMs != prior.localMTime || local.size != prior.size - { - actions.append(.orphan(relPath: prior.relPath, fileId: fileId)) - } else { - actions.append(.delete(relPath: prior.relPath, fileId: fileId)) - } + actions.append(.delete(relPath: prior.relPath, fileId: fileId)) } return actions } diff --git a/ios/Runner/Native/SyncEngine/SyncModels.swift b/ios/Runner/Native/SyncEngine/SyncModels.swift index 19a83e6..c5becde 100644 --- a/ios/Runner/Native/SyncEngine/SyncModels.swift +++ b/ios/Runner/Native/SyncEngine/SyncModels.swift @@ -38,10 +38,6 @@ enum SyncAction: Equatable { case upload(relPath: String, fileId: String) case delete(relPath: String, fileId: String) case conflict(SyncRemoteEntry, relPath: String) - /// Deleted on the server, but edited on this device since the last sync: - /// the edited copy is the only one left, so it's kept (and simply no longer - /// tracked) instead of being deleted with the rest. Android deletes it. - case orphan(relPath: String, fileId: String) } /// A file changed both on the device and on the server since the last sync. diff --git a/ios/Runner/Native/SyncEngine/SyncRunner.swift b/ios/Runner/Native/SyncEngine/SyncRunner.swift index 7cbee73..45547f2 100644 --- a/ios/Runner/Native/SyncEngine/SyncRunner.swift +++ b/ios/Runner/Native/SyncEngine/SyncRunner.swift @@ -144,7 +144,7 @@ final class SyncStatusBus { /// 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. -/// - A file deleted on the server but edited here is kept, not deleted. +/// - 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 { @@ -301,11 +301,6 @@ struct SyncRunner { } state[fileId] = nil - case .orphan(_, let fileId): - // Deleted on the server but edited here: the edit stays on the - // device, no longer tracked. - state[fileId] = nil - case .conflict(let entry, let rel): summary.conflicts.append( SyncConflict( diff --git a/ios/RunnerTests/SyncEngineTests.swift b/ios/RunnerTests/SyncEngineTests.swift index 395956e..f1804dc 100644 --- a/ios/RunnerTests/SyncEngineTests.swift +++ b/ios/RunnerTests/SyncEngineTests.swift @@ -583,19 +583,19 @@ final class SyncEngineTests: XCTestCase { XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone") } - func testDeletedOnTheServerButEditedHereIsKept() async throws { + 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, 0) - XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit") + 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) - XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit", "and not touched later either") + XCTAssertFalse(exists("Docs/a.txt"), "not restored on a later sync") XCTAssertFalse(again.changedAnything) } @@ -740,6 +740,36 @@ final class SyncEngineTests: XCTestCase { 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) diff --git a/lib/main.dart b/lib/main.dart index 6ed8da5..a6126b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,6 +32,7 @@ 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'; @@ -210,6 +211,7 @@ class MainShellView extends StatefulWidget { class _MainShellViewState extends State { late AppTab _currentTab; + final Set _selectionActiveTabs = {}; late final Map _scrollControllers; StreamSubscription>? _shareSub; StreamSubscription? _pickSub; @@ -430,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(), ], @@ -549,13 +568,15 @@ class _MainShellViewState extends State { bottomBarStyle, settings.bottomBarFrosted, ), - // Android-only extended Upload FAB - iOS uses the top bar's `plus` + // Android-only extended Upload FAB - iOS uses the top bar's 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, @@ -566,7 +587,22 @@ 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, @@ -576,6 +612,10 @@ class _MainShellViewState extends State { 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) { @@ -615,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/settings_controller.dart b/lib/providers/settings_controller.dart index 46f7f92..feb0851 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -9,11 +9,16 @@ import '../widgets/noo/nav/noo_nav_style.dart'; /// in Settings. enum SwipeAction { none, favorite, delete, share } -/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and +/// How the mobile Upload button is sized: [auto] expands on Files/Photos and /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] /// 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 @@ -55,6 +60,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'; @@ -65,6 +71,7 @@ class SettingsController extends ChangeNotifier { 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'; @@ -78,6 +85,7 @@ 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; @@ -87,6 +95,7 @@ class SettingsController extends ChangeNotifier { MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; bool _tapTabToScrollTop = true; + SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top; List _tabOrder = AppTab.values.toList(); Set _hiddenTabs = {}; @@ -134,12 +143,14 @@ class SettingsController extends ChangeNotifier { 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 @@ -180,6 +191,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( @@ -187,6 +203,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( @@ -356,6 +376,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; diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 672fbe4..dc27a09 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -366,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 5025e9b..5857e51 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -428,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( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index f0f7088..10e9ed6 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,12 +65,14 @@ 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 @@ -337,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 @@ -652,6 +657,10 @@ class _FilesViewState extends State { @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. @@ -835,8 +844,7 @@ class _FilesViewState extends State { child: _ExternalStorageHeader( count: externalItems.length, expanded: _externalExpanded, - onTap: () => - setState(() => _externalExpanded = !_externalExpanded), + onTap: () => setState(() => _externalExpanded = !_externalExpanded), ), ), if (_externalExpanded) @@ -854,17 +862,19 @@ class _FilesViewState extends State { // (+ 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 @@ -939,12 +949,17 @@ class _FilesViewState extends State { // 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 (NooBottomBar.drawsBehindBody( - context.watch().bottomBarStyle, - context.watch().bottomBarFrosted, - )) + 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), + ), ), ]; @@ -958,38 +973,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, + ), ), ), ), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index e618056..39ac518 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -19,7 +19,6 @@ 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'; @@ -31,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'; @@ -58,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(); @@ -82,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 @@ -215,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(); @@ -248,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( @@ -360,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( @@ -368,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, + ), ), ), ), @@ -554,17 +579,32 @@ class _PhotosViewState extends State { ), ), 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', ), - ), - ], + NooSegmentOption( + value: true, + icon: LucideIcons.heart, + label: 'Favorites only', + ), + ], + ), ), const SizedBox(height: 22), HiddenFilesFilterRow( 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 d20af01..b7d00c2 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,83 @@ 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 VoidCallback onExpand; + final VoidCallback onClose; + const AvatarMenuCard({ + super.key, + this.bottom = false, + this.movingAvatar = false, + this.showHeaderAvatar = true, + required this.expanded, + required this.onExpand, + required this.onClose, + }); @override Widget build(BuildContext context) { @@ -60,226 +91,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( - // Same side inset as the Files list's rows (`NooSpace.sm`), so the - // card is as wide as the list beneath it. - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - 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 + ? const SizedBox.square(dimension: 32) + : NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 32, + ), + ], + ), + ), + ); + final attached = bottom && !showHeaderAvatar; + 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 (!attached) 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 (attached) ...[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), + ), + if (bottom && !attached) header, + ], + ), + ), + ), + ), + ); + 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), ), ), ), @@ -289,6 +404,273 @@ 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(); + 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?; + if (box != null && (box.size.height - cardHeight).abs() > .5) { + setState(() => cardHeight = box.size.height); + } + }); + } + + 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() => ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: AvatarMenuCard( + bottom: bottom, + movingAvatar: true, + 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, + bottom ? target.bottom - 49 : target.top + 17, + 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(), + ), + ), + ), + ), + ), + 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) + 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; @@ -333,7 +715,7 @@ class _OtherAccountRow extends StatelessWidget { ), ), const SizedBox(width: 12), - NooAvatar(initials: accountInitial(name), current: false, size: 40), + 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 15418a9..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 @@ -23,6 +24,7 @@ class BottomNavBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; final VoidCallback? onSearchTap; + final bool avatarInBottomBar; const BottomNavBar({ super.key, @@ -35,6 +37,7 @@ class BottomNavBar extends StatelessWidget { required this.selectedIndex, required this.onDestinationSelected, this.onSearchTap, + this.avatarInBottomBar = false, }); @override @@ -50,11 +53,27 @@ class BottomNavBar extends StatelessWidget { 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/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 473443e..4c32105 100644 --- a/lib/widgets/noo/core/noo_button.dart +++ b/lib/widgets/noo/core/noo_button.dart @@ -50,6 +50,9 @@ class NooButton extends StatefulWidget { /// 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, @@ -60,6 +63,7 @@ class NooButton extends StatefulWidget { this.disabled = false, this.onTap, this.iconOnly = false, + this.expansion, }); @override @@ -108,11 +112,22 @@ class _NooButtonState extends State { 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!, + ), + ), + ), ), ], ); @@ -135,7 +150,17 @@ class _NooButtonState extends State { width: widget.iconOnly ? s.height : (widget.fullWidth ? double.infinity : null), - padding: _isTextVariant || widget.iconOnly + 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, 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 51a4f9e..8fd0ded 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -19,8 +19,8 @@ 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 @@ -78,6 +78,11 @@ class NooBottomBar extends StatelessWidget { 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, @@ -90,6 +95,8 @@ class NooBottomBar extends StatelessWidget { required this.onSelected, this.searchDestination, this.onSearchTap, + this.avatarSatellite, + this.avatarMenuProgress = 0, }); /// The row height [build] draws for [style]/[barStyle] - exposed so a @@ -126,7 +133,12 @@ class NooBottomBar extends StatelessWidget { // 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) @@ -162,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 @@ -202,37 +216,126 @@ class NooBottomBar extends StatelessWidget { ), 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, + ), + ], + ); + }, ); } @@ -242,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; @@ -268,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, @@ -277,13 +382,14 @@ class NooBottomBar extends StatelessWidget { ), ), if (trailingSearch != null) - Expanded( - child: _AndroidItem( + _searchRowItem( + _AndroidItem( destination: trailingSearch, selected: false, floating: floating, onTap: onSearchTap!, ), + itemWidth, ), ], ), @@ -362,11 +468,13 @@ class _SearchSatellite extends StatelessWidget { 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, }); @@ -377,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..d58e3a8 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -4,8 +4,10 @@ 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_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 +15,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 +35,30 @@ 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: [positionRow, orderList], ); } @@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget { const SizedBox(height: 4), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), const SizedBox(height: NooSpace.md), + positionRow, + const SizedBox(height: NooSpace.md), orderList, ], ), @@ -79,8 +95,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 +126,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 50267eb..ca9f89c 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -56,9 +56,9 @@ class SettingsAppearanceSection extends StatelessWidget { _BottomBarStyleRow(settings: settings), NooSettingsRow( icon: LucideIcons.glassWater, - label: const Text('Frosted glass bottom bar'), + label: const Text('Frosted glass'), subtitle: const Text( - 'A translucent, blurred bottom bar - content scrolls behind it', + 'Blurred bottom navigation, avatar menus and media viewer panels', ), trailing: NooToggle( checked: settings.bottomBarFrosted, @@ -67,6 +67,23 @@ class SettingsAppearanceSection extends StatelessWidget { ), 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'), @@ -96,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), ), @@ -116,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), - ), ], ), ], @@ -168,6 +191,7 @@ class _ThemeRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.themeMode, onChanged: settings.setThemeMode, options: const [ @@ -231,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.bottomBarStyle, onChanged: settings.setBottomBarStyle, options: const [ @@ -287,6 +312,7 @@ class _FrostedGlassRow extends StatelessWidget { 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) { @@ -402,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.navMenuStyle, onChanged: settings.setNavMenuStyle, options: const [ @@ -455,6 +482,7 @@ class _FabStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.fabStyle, onChanged: settings.setFabStyle, options: const [ @@ -508,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/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/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_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/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/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index f99a902..6223df9 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'; @@ -17,7 +18,10 @@ 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 @@ -86,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)); @@ -110,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, + ); + }, ), ), ), @@ -127,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)); } } @@ -224,4 +279,320 @@ void main() { 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('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/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/nav_test.dart b/test/widgets/noo/nav_test.dart index 84b1fd0..4018f01 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -215,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, @@ -238,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); + }, + ); + } + } +}