Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s

This commit is contained in:
Ayushya Amitabh
2026-10-06 23:18:50 -04:00
parent c5b46fd493
commit ce2273c9a6
39 changed files with 2309 additions and 647 deletions
+15 -7
View File
@@ -65,7 +65,8 @@ new provider instance):
without a circular dependency. without a circular dependency.
- [`SettingsController`](../../lib/providers/settings_controller.dart) — - [`SettingsController`](../../lib/providers/settings_controller.dart) —
global UI prefs independent of login state: theme mode/seed color/dynamic 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`/ order/visibility/default (`requestedTab`/`requestTab`/
`consumeRequestedTab`), swipe actions. `consumeRequestedTab`), swipe actions.
- [`FilesController`](../../lib/providers/files_controller.dart) — the - [`FilesController`](../../lib/providers/files_controller.dart) — the
@@ -282,11 +283,13 @@ the sidebar):
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar` - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
for *every* tab (previously only Files had shell-level top chrome, with for *every* tab (previously only Files had shell-level top chrome, with
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
large title, an inline search field, a `plus` action on Files only (no large title with Upload and the top account avatar on the same row,
other tab has a create/upload flow), and the account avatar; Android and an inline search field below; Android
gets a compact title row with `search`/avatar actions, relying on an gets a compact title row with `search`/avatar actions, relying on an
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar extended `NooFab` instead of a top-bar upload button. Both use the same
icon for upload. Rather than one shared instance in `Scaffold.appBar`, 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 `MainShellView` builds a separate `AppTopBar` per tab (labelled for that
tab) and each tab plants its own as the first sliver in its own tab) and each tab plants its own as the first sliver in its own
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`, `CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
@@ -312,8 +315,13 @@ the sidebar):
section yet). `SettingsController.navMenuStyle` (Settings → Appearance → section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
"Navigation menu") offers an alternative to the hamburger/drawer pair: "Navigation menu") offers an alternative to the hamburger/drawer pair:
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`) the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
instead, a dropdown holding the same hidden-tabs + Settings content - instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
see `styling.md`'s Gotchas for the wiring. 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, - **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
remaining tabs, storage meter, Settings) sits beside a `NooToolbar` remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
(tab title, search, an "Upload" action on Files/Photos) over the same (tab title, search, an "Upload" action on Files/Photos) over the same
+12 -5
View File
@@ -1021,10 +1021,10 @@ backup) with state in `<...>/sync-state/<accountId>/*.json`.
pruning uses `rmdir` and only on a successful empty read; a root's etag 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 shortcut marker is only written when the path synced cleanly *and* the state
was saved. was saved.
- **Deliberate differences from Android:** a file deleted on the server but - **Server deletions:** remove the local copy even if edited locally, matching
edited here is **kept** (untracked) rather than deleted - the edit is the Android.
only copy; after our own upload the file's etag is re-read so it isn't - **Deliberate differences from Android:** after our own upload the file's
downloaded back; state is flushed every 20 transfers; conflicts are kept 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 per account on the status bus; downloads write to a temp file first. No
ongoing progress notification (a run posts a summary, and conflict ongoing progress notification (a run posts a summary, and conflict
notifications with Keep local / Use server actions). notifications with Keep local / Use server actions).
@@ -1039,12 +1039,19 @@ backup) with state in `<...>/sync-state/<accountId>/*.json`.
(`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch + (`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch +
processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers
registered before launch finishes). `intervalMinutes` (floored at 15, like 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 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 it. An expiring task cancels its runs (they save state as they go) and is
completed exactly once (`OnceGate`). **Not testable in the simulator** completed exactly once (`OnceGate`). **Not testable in the simulator**
(needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier` (needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier`
debugger command). 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, - **Credentials:** `SyncConfigStore` keeps each account's server, auth header,
paths, interval and notify flag as one Keychain item paths, interval and notify flag as one Keychain item
(`AfterFirstUnlock`, so a background run works with the phone locked). (`AfterFirstUnlock`, so a background run works with the phone locked).
+65 -42
View File
@@ -123,7 +123,8 @@ Gotchas:
toggle, Shares' scope switcher), but pass `onSurface: true` for one toggle, Shares' scope switcher), but pass `onSurface: true` for one
placed inside a sheet or dialog (already `colors.surface`), or its pill placed inside a sheet or dialog (already `colors.surface`), or its pill
track blends invisibly into the sheet instead of reading as a grouped 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()` - Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
on its scrollable child, or pull-to-refresh silently can't be triggered 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) - 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/ `NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
view-mode row shared by Files and Offline (`showStorageScope: false` for view-mode row shared by Files and Offline (`showStorageScope: false` for
Offline), and reused as-is by Favorites. Offline), and reused as-is by Favorites.
- 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 - Hidden files and external storage are each a three-way segmented row
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`, (`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
in both the Files and Photos filter sheets), not toggles: 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 cloud (default) / external / all. Files and Photos each persist their own
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
`ui_show_hidden*` bools are still read as a fallback, `true` → include); `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: hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
regular items first, then a collapsible "External storage" section regular items first, then a collapsible "External storage" section
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open) (`_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 instead of a corner badge. `NooBottomBar` later gained its own, unrelated
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") - `NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
don't confuse the two: this one is still non-blurred, just inset with a don't confuse the two: this one is still non-blurred, just inset with a
`line` border and `nooDialogShadow` (the app's one other shadow user - see `line` border and `nooDialogShadow` (also used by floating selection bars - see
that constant's doc comment) instead of edge to edge (no opacity/blur knob that constant's doc comment) instead of edge to edge (no opacity/blur knob
either). `floating` ignores `NooNavStyle` and always uses the Android row either). `floating` ignores `NooNavStyle` and always uses the Android row
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; (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 - `onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`, use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
anywhere that needs the *current* cap on regular tabs. anywhere that needs the *current* cap on regular tabs.
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`, - `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
Settings → Appearance → "Navigation menu") picks what opens hidden tabs + navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or a persisted Avatar position choice (`ui_avatar_position`, Top by default).
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart) `AvatarNavigationHost` owns one 420ms open / 320ms close animation using
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu` `easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and its controls fade in after the expansion starts. Top moves the content pane
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) - down through `AvatarNavigationBody`, keeping bottom navigation stationary.
a caller passing a custom `onTap` *must* also pass a matching `label`, or Bottom moves the avatar upward into the card's bottom-right corner and
the tooltip/semantics still say "Accounts" for a button that no longer overlays the list immediately above navigation. Both use the Files gutter.
opens the account switcher. In `avatarMenu` mode `main.dart` also sets The floating rail beside a bottom avatar shows text only on the selected
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
the drawer. The menu header has a chevron that expands an account section with the avatar inside the trailing end. Its popup expands upward with
(other saved accounts to switch to, "Add Account", "Manage Accounts") full screen width, rounded top corners and no gap above the bar; the
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of avatar stays in the bar, and Search keeps its inline position.
`showGeneralDialog` directly (`barrierColor: Colors.transparent` + The attached popup has no outer border or shadow, blending into the bar.
`barrierDismissible: true` for a non-dimming click-outside-to-close menu, Attached popup order is navigation (including Settings), current account,
not a modal flow) - there's no existing anchored-popup primitive here then expanded secondary accounts and Add/Manage Account controls.
(`PopupMenuButton`'s own width doesn't stretch to a full content column), The iOS shell places Upload and the top avatar on the large-title row.
so don't reach for `showNooSheet`/`showNooDialog` for something shaped Upload uses the shared `ui_fab_style` preference: Auto expands on Files
like this. It's positioned just past the status bar (`SafeArea`'s own and Photos and collapses elsewhere; Mini is icon-only; Expanded always
inset, not the top bar's full height on top of that) so it covers the shows the plus-and-Upload button. Upload uses the folder icon's tonal
top bar - including the tab title - rather than sitting below it, and `accentSoft` background and `accentText` foreground. Search stays below.
its card carries two stacked `boxShadow`s rather than just Upload uses the compact 32px size to match the visible top avatar.
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough On iOS the accent picker hides the unsupported wallpaper color option;
to read as basically invisible on a small card over a dark theme's the accent subtitle, swatch and selection always reflect the seed color.
near-black `bg` (a dark, diffuse shadow needs real density close to the Upload's width, padding and label opacity interpolate over `NooMotion.base`.
edge to be visible against an already-dark backdrop), so a second, A shared shell animation keeps separately mounted tab headers in sync
tighter, more opaque contact shadow underneath it gives real elevation during Auto tab changes; reduced motion skips the transition.
in both themes. The card's border can go missing wherever an opaque row Avatar navigation and its bottom satellite share the bottom bar's frost
sits against it too (every corner but the header's, which has no toggle, blur and opacity, including throughout the popup animation.
full-bleed fill of its own) if a `Container` combines `border` with its The setting is labelled "Frosted glass" and also controls both media
own `clipBehavior` - that paints the border as part of the *outer* viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
decoration, then the clipped child on top right up to the same boundary, without a backdrop filter; On uses the shared blur and tint opacity.
with no gap for the border's own stroke to show through. Fixed the same Search starts inside the rail when a bottom avatar occupies the satellite;
way any bordered-and-clipped `Container` should be: no `clipBehavior` on its row slot smoothly collapses as Search fades into that satellite while
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius the avatar moves into the popup. Closing reverses the same path. The card
reduced by that same 1px) around the filled, clipped content instead, so retains hidden tabs, Settings, account switching, Add Account, and Manage
it never paints over the border. Also added 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 `NooTopBar.androidTitleTrailing`: Android has no large title to put a
second search row under the way iOS's `search:` slot does, so an inline second search row under the way iOS's `search:` slot does, so an inline
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when 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. `FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) — 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 `SeekBarPreview` wrapper used by the Settings style picker so every
preview always matches the real widget exactly (same painter, just fed preview always matches the real widget exactly (same painter, just fed
demo `progress`/`phase` values). Add new seek-bar presets here, not by demo `progress`/`phase` values). Add new seek-bar presets here, not by
forking the painter. 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 - Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
back button + filename, the bottom action bar, the video transport back button + filename, the bottom action bar, the video transport
controls) all share one small hand-rolled icon-button pattern controls) all share one small hand-rolled icon-button pattern
@@ -320,7 +320,13 @@ final class SyncCoordinator {
/// exactly once - when the work ends or when the system's time does. /// exactly once - when the work ends or when the system's time does.
private func handleBackground(_ task: BGTask) { private func handleBackground(_ task: BGTask) {
scheduleBackgroundWork() // line up the next one before this one runs 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 accountIds = backgroundConfigs().map(\.accountId)
let work = Task { [weak self] in let work = Task { [weak self] in
guard let self else { return gate.finish(false) } 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 // The system's time is up: stop the work and the runs it started (each
// saves what it has) and report. // saves what it has) and report.
task.expirationHandler = { [weak self] in return { [weak self] in
work.cancel() work.cancel()
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) } for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
gate.finish(false) gate.finish(false)
+1 -10
View File
@@ -89,16 +89,7 @@ enum SyncDiff {
for fileId in priorFolderFileIds where !seen.contains(fileId) { for fileId in priorFolderFileIds where !seen.contains(fileId) {
guard let prior = state[fileId] else { continue } guard let prior = state[fileId] else { continue }
// Deleted on the server. If it was also edited here since the last sync actions.append(.delete(relPath: prior.relPath, fileId: fileId))
// 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))
}
} }
return actions return actions
} }
@@ -38,10 +38,6 @@ enum SyncAction: Equatable {
case upload(relPath: String, fileId: String) case upload(relPath: String, fileId: String)
case delete(relPath: String, fileId: String) case delete(relPath: String, fileId: String)
case conflict(SyncRemoteEntry, relPath: 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. /// A file changed both on the device and on the server since the last sync.
@@ -144,7 +144,7 @@ final class SyncStatusBus {
/// edits with the server's copies). /// edits with the server's copies).
/// - Paths the server sends are checked to stay inside the mirror, and two /// - 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. /// 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 /// - A root's etag is only remembered once its whole path synced cleanly, and
/// only if the state it relies on was actually saved. /// only if the state it relies on was actually saved.
struct SyncRunner { struct SyncRunner {
@@ -301,11 +301,6 @@ struct SyncRunner {
} }
state[fileId] = nil 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): case .conflict(let entry, let rel):
summary.conflicts.append( summary.conflicts.append(
SyncConflict( SyncConflict(
+34 -4
View File
@@ -583,19 +583,19 @@ final class SyncEngineTests: XCTestCase {
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone") XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone")
} }
func testDeletedOnTheServerButEditedHereIsKept() async throws { func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() async throws {
seedServer() seedServer()
_ = await runner.run(config, force: false) _ = await runner.run(config, force: false)
try editLocally("Docs/a.txt", "my only copy of this edit") try editLocally("Docs/a.txt", "my only copy of this edit")
FakeDav.remove("/Docs/a.txt") FakeDav.remove("/Docs/a.txt")
let summary = await runner.run(config, force: false) let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.deleted, 0) XCTAssertEqual(summary.deleted, 1)
XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit") XCTAssertFalse(exists("Docs/a.txt"))
XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked") XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked")
let again = await runner.run(config, force: false) 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) XCTAssertFalse(again.changedAnything)
} }
@@ -740,6 +740,36 @@ final class SyncEngineTests: XCTestCase {
XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's") 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() { func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() {
let service = "test-\(UUID().uuidString)" let service = "test-\(UUID().uuidString)"
let configs = SyncConfigStore(service: service) let configs = SyncConfigStore(service: service)
+62 -22
View File
@@ -32,6 +32,7 @@ import 'views/share_upload_view.dart';
import 'widgets/app_drawer.dart'; import 'widgets/app_drawer.dart';
import 'widgets/app_tab_view_builder.dart'; import 'widgets/app_tab_view_builder.dart';
import 'widgets/app_top_bar.dart'; import 'widgets/app_top_bar.dart';
import 'widgets/avatar_menu.dart';
import 'widgets/bottom_nav_bar.dart'; import 'widgets/bottom_nav_bar.dart';
import 'widgets/create_menu.dart'; import 'widgets/create_menu.dart';
import 'widgets/noo/core/noo_avatar.dart'; import 'widgets/noo/core/noo_avatar.dart';
@@ -210,6 +211,7 @@ class MainShellView extends StatefulWidget {
class _MainShellViewState extends State<MainShellView> { class _MainShellViewState extends State<MainShellView> {
late AppTab _currentTab; late AppTab _currentTab;
final Set<AppTab> _selectionActiveTabs = {};
late final Map<AppTab, ScrollController> _scrollControllers; late final Map<AppTab, ScrollController> _scrollControllers;
StreamSubscription<List<SharedFileRef>>? _shareSub; StreamSubscription<List<SharedFileRef>>? _shareSub;
StreamSubscription<PickRequest>? _pickSub; StreamSubscription<PickRequest>? _pickSub;
@@ -430,24 +432,41 @@ class _MainShellViewState extends State<MainShellView> {
// null`). // null`).
final tabStack = Stack( final tabStack = Stack(
children: [ children: [
IndexedStack( AvatarNavigationBody(
index: selectedIndex, child: IndexedStack(
children: displayTabs index: selectedIndex,
.map( children: displayTabs
(tab) => buildAppTabView( .map(
tab, (tab) => buildAppTabView(
_scrollControllers[tab]!, tab,
topBar: isDesktop || pickRequest != null _scrollControllers[tab]!,
? null onSelectionChanged: (selecting) {
: AppTopBar( if (!mounted ||
style: navStyle, selecting == _selectionActiveTabs.contains(tab)) {
tab: tab, return;
searchInBottomBar: showBottomBarSearch, }
navMenuStyle: settings.navMenuStyle, setState(() {
), if (selecting) {
), _selectionActiveTabs.add(tab);
) } else {
.toList(), _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(), if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
], ],
@@ -549,13 +568,15 @@ class _MainShellViewState extends State<MainShellView> {
bottomBarStyle, bottomBarStyle,
settings.bottomBarFrosted, 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 // instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos, // aside) and collapses to an icon-only circle off Files/Photos,
// rather than the Scaffold popping it fully in/out on every tab // rather than the Scaffold popping it fully in/out on every tab
// switch - see NooFab's [collapsed]. // switch - see NooFab's [collapsed].
floatingActionButton: floatingActionButton:
pickRequest == null && navStyle == NooNavStyle.android pickRequest == null &&
navStyle == NooNavStyle.android &&
!_selectionActiveTabs.contains(selectedTab)
? NooFab( ? NooFab(
collapsed: switch (settings.fabStyle) { collapsed: switch (settings.fabStyle) {
FabStyle.auto => !canUpload, FabStyle.auto => !canUpload,
@@ -566,7 +587,22 @@ class _MainShellViewState extends State<MainShellView> {
onTap: () => showCreateMenu(context), onTap: () => showCreateMenu(context),
) )
: null, : null,
body: tabStack, body: TweenAnimationBuilder<double>(
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( bottomNavigationBar: BottomNavBar(
style: navStyle, style: navStyle,
barStyle: bottomBarStyle, barStyle: bottomBarStyle,
@@ -576,6 +612,10 @@ class _MainShellViewState extends State<MainShellView> {
tabs: pinnedTabs, tabs: pinnedTabs,
selectedIndex: pinnedIndex, selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
avatarInBottomBar:
pickRequest == null &&
settings.navMenuStyle == NooNavMenuStyle.avatarMenu &&
settings.avatarPosition == AvatarPosition.bottom,
onDestinationSelected: (index) { onDestinationSelected: (index) {
final tappedTab = pinnedTabs[index]; final tappedTab = pinnedTabs[index];
if (tappedTab == _currentTab) { if (tappedTab == _currentTab) {
@@ -615,7 +655,7 @@ class _MainShellViewState extends State<MainShellView> {
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop && pickRequest != null) pick.cancelPick(); if (!didPop && pickRequest != null) pick.cancelPick();
}, },
child: scaffold, child: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold),
); );
} }
} }
+37 -1
View File
@@ -9,11 +9,16 @@ import '../widgets/noo/nav/noo_nav_style.dart';
/// in Settings. /// in Settings.
enum SwipeAction { none, favorite, delete, share } enum SwipeAction { none, favorite, delete, share }
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and /// How the mobile Upload button is sized: [auto] expands on Files/Photos and
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
/// always shows the label. /// always shows the label.
enum FabStyle { auto, mini, expanded } 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 /// Thumb/track presets for the video player's seek bar, matching the four
/// combinations offered by other Material You media players: a Material 3 /// combinations offered by other Material You media players: a Material 3
/// slider-style thumb ([classic]), an animated travelling wave with a round /// slider-style thumb ([classic]), an animated travelling wave with a round
@@ -55,6 +60,7 @@ class SettingsController extends ChangeNotifier {
static const _prefUseDynamicColor = 'ui_use_dynamic_color'; static const _prefUseDynamicColor = 'ui_use_dynamic_color';
static const _prefSeedColor = 'ui_seed_color'; static const _prefSeedColor = 'ui_seed_color';
static const _prefTabOrder = 'ui_tab_order'; static const _prefTabOrder = 'ui_tab_order';
static const _prefSelectionBarPosition = 'ui_selection_bar_position';
static const _prefSelectionActionOrder = 'ui_selection_action_order'; static const _prefSelectionActionOrder = 'ui_selection_action_order';
static const _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefHiddenTabs = 'ui_hidden_tabs';
static const _prefDefaultTab = 'ui_default_tab'; static const _prefDefaultTab = 'ui_default_tab';
@@ -65,6 +71,7 @@ class SettingsController extends ChangeNotifier {
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted'; static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur'; static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur';
static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity'; static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity';
static const _prefAvatarPosition = 'ui_avatar_position';
static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefNavMenuStyle = 'ui_nav_menu_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefAmoledDark = 'ui_amoled_dark';
@@ -78,6 +85,7 @@ class SettingsController extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
AvatarPosition _avatarPosition = AvatarPosition.top;
bool _bottomBarFrosted = false; bool _bottomBarFrosted = false;
double _frostedBlur = FrostedGlassPreset.standard.blur; double _frostedBlur = FrostedGlassPreset.standard.blur;
double _frostedOpacity = FrostedGlassPreset.standard.opacity; double _frostedOpacity = FrostedGlassPreset.standard.opacity;
@@ -87,6 +95,7 @@ class SettingsController extends ChangeNotifier {
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
bool _tapTabToScrollTop = true; bool _tapTabToScrollTop = true;
SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top;
List<AppTab> _tabOrder = AppTab.values.toList(); List<AppTab> _tabOrder = AppTab.values.toList();
Set<AppTab> _hiddenTabs = {}; Set<AppTab> _hiddenTabs = {};
@@ -134,12 +143,14 @@ class SettingsController extends ChangeNotifier {
FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values
.where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity) .where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity)
.firstOrNull; .firstOrNull;
AvatarPosition get avatarPosition => _avatarPosition;
NooNavMenuStyle get navMenuStyle => _navMenuStyle; NooNavMenuStyle get navMenuStyle => _navMenuStyle;
bool get searchInBottomBar => _searchInBottomBar; bool get searchInBottomBar => _searchInBottomBar;
bool get useDynamicColor => _useDynamicColor; bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark; bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
bool get tapTabToScrollTop => _tapTabToScrollTop; bool get tapTabToScrollTop => _tapTabToScrollTop;
SelectionBarPosition get selectionBarPosition => _selectionBarPosition;
/// The cap a screen should actually enforce for the *regular*, user- /// The cap a screen should actually enforce for the *regular*, user-
/// reorderable tabs - one below [defaultMaxVisibleTabs] while /// reorderable tabs - one below [defaultMaxVisibleTabs] while
@@ -180,6 +191,11 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system, orElse: () => ThemeMode.system,
); );
} }
_selectionBarPosition = SelectionBarPosition.values.firstWhere(
(position) =>
position.name == prefs.getString(_prefSelectionBarPosition),
orElse: () => SelectionBarPosition.top,
);
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle); final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) { if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere( _bottomBarStyle = NooBottomBarStyle.values.firstWhere(
@@ -187,6 +203,10 @@ class SettingsController extends ChangeNotifier {
orElse: () => _bottomBarStyle, orElse: () => _bottomBarStyle,
); );
} }
_avatarPosition = AvatarPosition.values.firstWhere(
(position) => position.name == prefs.getString(_prefAvatarPosition),
orElse: () => AvatarPosition.top,
);
final navMenuStyleName = prefs.getString(_prefNavMenuStyle); final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
if (navMenuStyleName != null) { if (navMenuStyleName != null) {
_navMenuStyle = NooNavMenuStyle.values.firstWhere( _navMenuStyle = NooNavMenuStyle.values.firstWhere(
@@ -356,6 +376,22 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); _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) { void setBottomBarStyle(NooBottomBarStyle style) {
if (_bottomBarStyle == style) return; if (_bottomBarStyle == style) return;
_bottomBarStyle = style; _bottomBarStyle = style;
+2 -2
View File
@@ -366,8 +366,8 @@ class NooMotion {
/// The one shadow the design system allows, from `tokens/spacing.css`'s /// The one shadow the design system allows, from `tokens/spacing.css`'s
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s /// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
/// bar/search satellite (the only other thing in the app that's genuinely /// bar/search satellite, and the floating selection action bar.
/// floating above other content). Nothing else should use a `BoxShadow`. /// Nothing else should use a `BoxShadow`.
const nooDialogShadow = BoxShadow( const nooDialogShadow = BoxShadow(
color: Color(0x241E002F), // rgba(30,0,47,.14) color: Color(0x241E002F), // rgba(30,0,47,.14)
blurRadius: 48, blurRadius: 48,
+54 -28
View File
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/selection_bar_overlay.dart';
import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart'; 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 /// This tab's own shell top bar, planted as its first sliver - see
/// `buildAppTabView`'s doc comment. Null on desktop and while picking. /// `buildAppTabView`'s doc comment. Null on desktop and while picking.
final PreferredSizeWidget? topBar; final PreferredSizeWidget? topBar;
final ValueChanged<bool>? onSelectionChanged;
const FavoritesView({super.key, required this.scrollController, this.topBar}); const FavoritesView({
super.key,
required this.scrollController,
this.topBar,
this.onSelectionChanged,
});
@override @override
State<FavoritesView> createState() => _FavoritesViewState(); State<FavoritesView> createState() => _FavoritesViewState();
@@ -72,10 +79,12 @@ class _FavoritesViewState extends State<FavoritesView> {
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
widget.onSelectionChanged?.call(_isSelecting);
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); setState(_selectedIds.clear);
widget.onSelectionChanged?.call(false);
} }
/// A favorited folder switches to the Files tab, navigated there; a /// A favorited folder switches to the Files tab, navigated there; a
@@ -483,6 +492,10 @@ class _FavoritesViewState extends State<FavoritesView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectionAtBottom =
_isSelecting &&
context.watch<SettingsController>().selectionBarPosition ==
SelectionBarPosition.bottom;
final colors = context.nooColors; final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context); final isDesktop = NooLayout.isDesktop(context);
final favoritesController = context.watch<FavoritesController>(); final favoritesController = context.watch<FavoritesController>();
@@ -513,15 +526,17 @@ class _FavoritesViewState extends State<FavoritesView> {
final contentSlivers = <Widget>[ final contentSlivers = <Widget>[
if (widget.topBar != null) topBarSliver(widget.topBar!), if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPersistentHeader( if (!selectionAtBottom)
pinned: !_isSelecting, SliverPersistentHeader(
delegate: StickyHeaderDelegate( pinned: true,
height: _isSelecting ? 56 : 64, delegate: StickyHeaderDelegate(
child: _isSelecting floating: _isSelecting,
? _buildSelectionBar(context, selectedItems) height: _isSelecting ? 56 : 64,
: controlsRow, child: _isSelecting
? _buildSelectionBar(context, selectedItems)
: controlsRow,
),
), ),
),
if (favoritesController.isLoading && favorites.isEmpty) if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
@@ -646,7 +661,13 @@ class _FavoritesViewState extends State<FavoritesView> {
}, childCount: favorites.length), }, childCount: favorites.length),
), ),
), ),
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), SliverToBoxAdapter(
child: SizedBox(
height:
bottomBarClearance(context) +
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
),
),
]; ];
return PopScope( return PopScope(
@@ -654,24 +675,29 @@ class _FavoritesViewState extends State<FavoritesView> {
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection(); if (!didPop && _isSelecting) _clearSelection();
}, },
child: ColoredBox( child: SelectionBarOverlay(
color: colors.bg, bar: selectionAtBottom
child: RefreshIndicator( ? _buildSelectionBar(context, selectedItems)
color: colors.accent, : null,
backgroundColor: colors.surface, child: ColoredBox(
onRefresh: favoritesController.fetchAll, color: colors.bg,
// See files_view.dart's identical fix - without this, the sticky child: RefreshIndicator(
// controls row rides up under the status bar once the floating color: colors.accent,
// top bar above it fully collapses. backgroundColor: colors.surface,
child: SafeArea( onRefresh: favoritesController.fetchAll,
top: true, // See files_view.dart's identical fix - without this, the sticky
bottom: false, // controls row rides up under the status bar once the floating
child: CustomScrollView( // top bar above it fully collapses.
controller: widget.scrollController, child: SafeArea(
// See files_view.dart's identical fix - without this, pull-to- top: true,
// refresh can't be triggered on an empty or single-item list. bottom: false,
physics: const AlwaysScrollableScrollPhysics(), child: CustomScrollView(
slivers: contentSlivers, 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,
),
), ),
), ),
), ),
+1 -3
View File
@@ -428,9 +428,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// The stage follows the app's own theme (`bg`) rather than being pinned // 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 // 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 // next to the rest of the light-themed app. The floating top/action
// bars now follow the theme too (FrostedGlassContainer's own `surface` // bars follow the theme and share the navigation frost preference.
// default), at a higher blur opacity to stay legible over arbitrary
// photo/video brightness underneath.
final stageColor = colors.bg; final stageColor = colors.bg;
return Scaffold( return Scaffold(
+69 -49
View File
@@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/selection_bar_overlay.dart';
import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart'; 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 /// This tab's own shell top bar, planted as its first sliver - see
/// `buildAppTabView`'s doc comment. Null on desktop and while picking. /// `buildAppTabView`'s doc comment. Null on desktop and while picking.
final PreferredSizeWidget? topBar; final PreferredSizeWidget? topBar;
final ValueChanged<bool>? onSelectionChanged;
const FilesView({ const FilesView({
super.key, super.key,
required this.scrollController, required this.scrollController,
this.offline = false, this.offline = false,
this.topBar, this.topBar,
this.onSelectionChanged,
}); });
@override @override
@@ -337,10 +340,12 @@ class _FilesViewState extends State<FilesView> {
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
widget.onSelectionChanged?.call(_isSelecting);
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); setState(_selectedIds.clear);
widget.onSelectionChanged?.call(false);
} }
/// Routes a tap on an item, in picking, selecting or plain-browsing mode /// Routes a tap on an item, in picking, selecting or plain-browsing mode
@@ -652,6 +657,10 @@ class _FilesViewState extends State<FilesView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectionAtBottom =
_isSelecting &&
context.watch<SettingsController>().selectionBarPosition ==
SelectionBarPosition.bottom;
final colors = context.nooColors; final colors = context.nooColors;
// Display prefs (grid/list, filters, sort) live on FilesController for // Display prefs (grid/list, filters, sort) live on FilesController for
// both tabs; `browser` is the tab's own folder listing. // both tabs; `browser` is the tab's own folder listing.
@@ -835,8 +844,7 @@ class _FilesViewState extends State<FilesView> {
child: _ExternalStorageHeader( child: _ExternalStorageHeader(
count: externalItems.length, count: externalItems.length,
expanded: _externalExpanded, expanded: _externalExpanded,
onTap: () => onTap: () => setState(() => _externalExpanded = !_externalExpanded),
setState(() => _externalExpanded = !_externalExpanded),
), ),
), ),
if (_externalExpanded) if (_externalExpanded)
@@ -854,17 +862,19 @@ class _FilesViewState extends State<FilesView> {
// (+ breadcrumbs), while selecting it's the selection bar (see // (+ breadcrumbs), while selecting it's the selection bar (see
// `topRow` above): either way it's the one thing that always stays // `topRow` above): either way it's the one thing that always stays
// at the very top of the scroll view. // at the very top of the scroll view.
SliverPersistentHeader( if (!selectionAtBottom)
pinned: true, SliverPersistentHeader(
delegate: StickyHeaderDelegate( pinned: true,
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's delegate: StickyHeaderDelegate(
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present]. floating: _isSelecting,
// Getting this wrong overflows the sliver header by exactly the // 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
// shortfall - a real bug this shipped with once already. // fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64), // Getting this wrong overflows the sliver header by exactly the
child: topRow, // shortfall - a real bug this shipped with once already.
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
child: topRow,
),
), ),
),
if (_offline) if (_offline)
SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)), SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)),
// Files List / Grid // Files List / Grid
@@ -939,12 +949,17 @@ class _FilesViewState extends State<FilesView> {
// draws the body behind the bar instead (as does a frosted bar), so it // 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 // needs the bar's own footprint added on top or the last row ends up
// under it. // under it.
if (NooBottomBar.drawsBehindBody( if (selectionAtBottom ||
context.watch<SettingsController>().bottomBarStyle, NooBottomBar.drawsBehindBody(
context.watch<SettingsController>().bottomBarFrosted, context.watch<SettingsController>().bottomBarStyle,
)) context.watch<SettingsController>().bottomBarFrosted,
))
SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox(height: bottomBarClearance(context)), child: SizedBox(
height:
bottomBarClearance(context) +
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
),
), ),
]; ];
@@ -958,38 +973,43 @@ class _FilesViewState extends State<FilesView> {
browser.navigateUp(); browser.navigateUp();
} }
}, },
child: ColoredBox( child: SelectionBarOverlay(
color: colors.bg, bar: selectionAtBottom
child: RefreshIndicator( ? _buildSelectionBar(context, selectedItems)
color: colors.accent, : null,
backgroundColor: colors.surface, child: ColoredBox(
onRefresh: () { color: colors.bg,
unawaited(sync.syncOnPull()); child: RefreshIndicator(
return browser.reload(); color: colors.accent,
}, backgroundColor: colors.surface,
// `topBarSliver`'s floating header can collapse all the way to onRefresh: () {
// zero height (fully scrolled away), at which point the sticky unawaited(sync.syncOnPull());
// controls row right below it in `contentSlivers` would otherwise return browser.reload();
// ride up underneath the status bar instead of stopping below it },
// - the floating top bar used to be the only thing reserving that // `topBarSliver`'s floating header can collapse all the way to
// space (via its own internal `SafeArea`), and that reservation // zero height (fully scrolled away), at which point the sticky
// disappears along with it once it's fully hidden. Wrapping the // controls row right below it in `contentSlivers` would otherwise
// whole scroll view keeps the inset outside the scrolling region // ride up underneath the status bar instead of stopping below it
// entirely, so it's never implicated in the floating header's own // - the floating top bar used to be the only thing reserving that
// collapse/reveal math - safe to apply unconditionally, since // space (via its own internal `SafeArea`), and that reservation
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no // disappears along with it once it's fully hidden. Wrapping the
// status bar there). // whole scroll view keeps the inset outside the scrolling region
child: SafeArea( // entirely, so it's never implicated in the floating header's own
top: true, // collapse/reveal math - safe to apply unconditionally, since
bottom: false, // desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
child: CustomScrollView( // status bar there).
controller: widget.scrollController, child: SafeArea(
// Pull-to-refresh needs a scroll physics that allows dragging top: true,
// past the edge even when content doesn't fill the viewport - bottom: false,
// an empty or single-item list otherwise can't be pulled at child: CustomScrollView(
// all under the platform default physics. controller: widget.scrollController,
physics: const AlwaysScrollableScrollPhysics(), // Pull-to-refresh needs a scroll physics that allows dragging
slivers: contentSlivers, // 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,
),
), ),
), ),
), ),
+86 -46
View File
@@ -19,7 +19,6 @@ import '../widgets/filter_mode_rows.dart';
import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/core/noo_chip.dart'; import '../widgets/noo/core/noo_chip.dart';
import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/core/noo_toggle.dart';
import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_selection_bar.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/share_sheet.dart';
import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/selection_bar_overlay.dart';
import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.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 /// This tab's own shell top bar, planted as its first sliver - see
/// `buildAppTabView`'s doc comment. Null on desktop and while picking. /// `buildAppTabView`'s doc comment. Null on desktop and while picking.
final PreferredSizeWidget? topBar; final PreferredSizeWidget? topBar;
final ValueChanged<bool>? onSelectionChanged;
const PhotosView({super.key, required this.scrollController, this.topBar}); const PhotosView({
super.key,
required this.scrollController,
this.topBar,
this.onSelectionChanged,
});
@override @override
State<PhotosView> createState() => _PhotosViewState(); State<PhotosView> createState() => _PhotosViewState();
@@ -82,10 +88,12 @@ class _PhotosViewState extends State<PhotosView> {
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
widget.onSelectionChanged?.call(_isSelecting);
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); setState(_selectedIds.clear);
widget.onSelectionChanged?.call(false);
} }
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
@@ -215,6 +223,10 @@ class _PhotosViewState extends State<PhotosView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectionAtBottom =
_isSelecting &&
context.watch<SettingsController>().selectionBarPosition ==
SelectionBarPosition.bottom;
final colors = context.nooColors; final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context); final isDesktop = NooLayout.isDesktop(context);
final photosController = context.watch<PhotosController>(); final photosController = context.watch<PhotosController>();
@@ -248,23 +260,25 @@ class _PhotosViewState extends State<PhotosView> {
if (widget.topBar != null) topBarSliver(widget.topBar!), if (widget.topBar != null) topBarSliver(widget.topBar!),
// Sticky while browsing; once selecting, the selection bar takes over // Sticky while browsing; once selecting, the selection bar takes over
// the same slot instead. // the same slot instead.
SliverPersistentHeader( if (!selectionAtBottom)
pinned: !_isSelecting, SliverPersistentHeader(
delegate: StickyHeaderDelegate( pinned: true,
height: _isSelecting ? 56 : 64, delegate: StickyHeaderDelegate(
child: _isSelecting floating: _isSelecting,
? _buildSelectionBar(context, pick, selectedItems) height: _isSelecting ? 56 : 64,
: Padding( child: _isSelecting
padding: const EdgeInsets.fromLTRB( ? _buildSelectionBar(context, pick, selectedItems)
NooSpace.sm, : Padding(
NooSpace.sm, padding: const EdgeInsets.fromLTRB(
NooSpace.sm, NooSpace.sm,
NooSpace.xs, NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: controlsRow,
), ),
child: controlsRow, ),
),
), ),
),
if (photosController.isLoading && allPhotos.isEmpty) if (photosController.isLoading && allPhotos.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
@@ -360,7 +374,13 @@ class _PhotosViewState extends State<PhotosView> {
// Clearance so the last row isn't hidden behind the nav bar, // Clearance so the last row isn't hidden behind the nav bar,
// regardless of grid length - see `bottomBarClearance`'s own doc // regardless of grid length - see `bottomBarClearance`'s own doc
// comment for why this has to be dynamic rather than a flat 100. // 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( return PopScope(
@@ -368,24 +388,29 @@ class _PhotosViewState extends State<PhotosView> {
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection(); if (!didPop && _isSelecting) _clearSelection();
}, },
child: ColoredBox( child: SelectionBarOverlay(
color: colors.bg, bar: selectionAtBottom
child: RefreshIndicator( ? _buildSelectionBar(context, pick, selectedItems)
color: colors.accent, : null,
backgroundColor: colors.surface, child: ColoredBox(
onRefresh: photosController.fetchAllMedia, color: colors.bg,
// See files_view.dart's identical fix - without this, the sticky child: RefreshIndicator(
// controls row rides up under the status bar once the floating color: colors.accent,
// top bar above it fully collapses. backgroundColor: colors.surface,
child: SafeArea( onRefresh: photosController.fetchAllMedia,
top: true, // See files_view.dart's identical fix - without this, the sticky
bottom: false, // controls row rides up under the status bar once the floating
child: CustomScrollView( // top bar above it fully collapses.
controller: widget.scrollController, child: SafeArea(
// See files_view.dart's identical fix - without this, pull-to- top: true,
// refresh can't be triggered on an empty or single-item list. bottom: false,
physics: const AlwaysScrollableScrollPhysics(), child: CustomScrollView(
slivers: contentSlivers, 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<PhotosView> {
), ),
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
NooGroupedList( FilterSection(
children: [ icon: LucideIcons.heart,
NooSettingsRow( title: 'Favorites',
icon: LucideIcons.heart, child: NooSegmentedControl<bool>(
label: const Text('Favorites only'), fill: true,
trailing: NooToggle( onSurface: true,
checked: photos.showFavoritesOnly, labelOnlySelected: true,
onChanged: (_) => photos.toggleFavoritesFilter(), 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), const SizedBox(height: 22),
HiddenFilesFilterRow( HiddenFilesFilterRow(
+13 -2
View File
@@ -24,6 +24,7 @@ Widget buildAppTabView(
AppTab tab, AppTab tab,
ScrollController controller, { ScrollController controller, {
PreferredSizeWidget? topBar, PreferredSizeWidget? topBar,
ValueChanged<bool>? onSelectionChanged,
}) { }) {
switch (tab) { switch (tab) {
case AppTab.files: case AppTab.files:
@@ -33,11 +34,20 @@ Widget buildAppTabView(
key: const ValueKey('files'), key: const ValueKey('files'),
scrollController: controller, scrollController: controller,
topBar: topBar, topBar: topBar,
onSelectionChanged: onSelectionChanged,
); );
case AppTab.photos: case AppTab.photos:
return PhotosView(scrollController: controller, topBar: topBar); return PhotosView(
scrollController: controller,
topBar: topBar,
onSelectionChanged: onSelectionChanged,
);
case AppTab.favorites: case AppTab.favorites:
return FavoritesView(scrollController: controller, topBar: topBar); return FavoritesView(
scrollController: controller,
topBar: topBar,
onSelectionChanged: onSelectionChanged,
);
case AppTab.activity: case AppTab.activity:
return ActivityView(scrollController: controller, topBar: topBar); return ActivityView(scrollController: controller, topBar: topBar);
case AppTab.trash: case AppTab.trash:
@@ -52,6 +62,7 @@ Widget buildAppTabView(
scrollController: controller, scrollController: controller,
offline: true, offline: true,
topBar: topBar, topBar: topBar,
onSelectionChanged: onSelectionChanged,
); );
} }
} }
+77 -17
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../providers/settings_controller.dart';
import 'avatar_menu.dart'; import 'avatar_menu.dart';
import 'create_menu.dart'; import 'create_menu.dart';
import 'noo/nav/noo_top_bar.dart'; import 'noo/nav/noo_top_bar.dart';
import 'noo/core/noo_button.dart';
import '../theme/design_tokens.dart';
import 'shell/shell_common.dart'; import 'shell/shell_common.dart';
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular /// 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). /// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
/// Built on [NooTopBar]: /// Built on [NooTopBar]:
/// ///
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a /// - iOS: large title, Upload button and account avatar in one row, then
/// create/upload flow) and the account avatar trailing, then the large /// an inline search field that opens [SearchView]. Upload shares Android's
/// title, then an inline search field that opens [SearchView]. /// Auto/Collapsed/Expanded preference.
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
/// here - it's the [NooFab] `MainShellView` shows instead. When search /// here - it's the [NooFab] `MainShellView` shows instead. When search
/// isn't in the bottom bar, an inline search-field-styled launcher /// 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 AppTab tab;
final bool searchInBottomBar; final bool searchInBottomBar;
final NooNavMenuStyle navMenuStyle; final NooNavMenuStyle navMenuStyle;
final AvatarPosition avatarPosition;
final FabStyle uploadButtonStyle;
const AppTopBar({ const AppTopBar({
super.key, super.key,
@@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
required this.tab, required this.tab,
this.searchInBottomBar = false, this.searchInBottomBar = false,
this.navMenuStyle = NooNavMenuStyle.drawer, this.navMenuStyle = NooNavMenuStyle.drawer,
this.avatarPosition = AvatarPosition.top,
this.uploadButtonStyle = FabStyle.auto,
}); });
bool get _ios => style == NooNavStyle.ios; bool get _ios => style == NooNavStyle.ios;
bool get _showSearch => !searchInBottomBar; bool get _showSearch => !searchInBottomBar;
bool get _uploadCollapsed => switch (uploadButtonStyle) {
FabStyle.auto => tab != AppTab.files && tab != AppTab.photos,
FabStyle.mini => true,
FabStyle.expanded => false,
};
@override @override
Size get preferredSize => NooTopBar( Size get preferredSize => NooTopBar(
style: style, style: style,
title: tab.label, title: tab.label,
iosActionsInTitleRow: true,
// A throwaway placeholder purely to make preferredSize's "is there a // A throwaway placeholder purely to make preferredSize's "is there a
// search slot" check match what build() actually shows - never // search slot" check match what build() actually shows - never
// inserted into the tree, so its content doesn't matter. // inserted into the tree, so its content doesn't matter.
@@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
return NooTopBar( return NooTopBar(
style: style, style: style,
title: tab.label, title: tab.label,
iosActionsInTitleRow: true,
onMenu: navMenuStyle == NooNavMenuStyle.drawer onMenu: navMenuStyle == NooNavMenuStyle.drawer
? () => Scaffold.of(context).openDrawer() ? () => Scaffold.of(context).openDrawer()
: null, : null,
@@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
? const ShellSearchLauncher() ? const ShellSearchLauncher()
: null, : null,
actions: [ actions: [
if (_ios && tab == AppTab.files) if (_ios)
NooTopBarButton( Tooltip(
icon: LucideIcons.plus, message: 'Upload',
tooltip: 'Create', child: Semantics(
onTap: () => showCreateMenu(context), 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<double>(
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, 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;
}
+622 -240
View File
@@ -1,3 +1,4 @@
import 'dart:ui' show ImageFilter, lerpDouble;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -6,53 +7,83 @@ import '../providers/session_controller.dart';
import '../providers/settings_controller.dart'; import '../providers/settings_controller.dart';
import '../providers/trash_controller.dart'; import '../providers/trash_controller.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import 'noo/core/noo_avatar.dart';
import '../views/login_view.dart'; import '../views/login_view.dart';
import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_badge.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 'noo/lists/noo_settings_row.dart';
import 'shell/shell_common.dart'; import 'shell/shell_common.dart';
/// The dropdown [ShellAvatarButton] opens when /// The shell supplies the expanding-card presentation. Standalone top bars
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] - /// retain a dialog fallback (for screens outside the shell).
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs, Future<void> showAvatarMenu(BuildContext context) async {
/// Settings), reached from the avatar instead of a hamburger-triggered final scope = AvatarNavigationScope.maybeOf(context);
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/ if (scope != null) {
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming) scope._host.openFrom(context);
/// barrier and a card anchored under the top bar rather than a modal return;
/// 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 await showGeneralDialog<void>(
/// content column the way this needs to).
Future<void> showAvatarMenu(BuildContext context) {
return showGeneralDialog<void>(
context: context, context: context,
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
barrierDismissible: true, barrierDismissible: true,
barrierLabel: 'Close menu', barrierLabel: 'Close menu',
transitionDuration: NooMotion.fast, transitionDuration: NooMotion.base,
pageBuilder: (context, _, _) => const _AvatarMenuContent(), 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( transitionBuilder: (context, animation, _, child) => FadeTransition(
opacity: animation, opacity: animation,
child: ScaleTransition( child: ScaleTransition(
alignment: Alignment.topRight, alignment: Alignment.topRight,
scale: Tween<double>( scale: Tween(begin: .88, end: 1.0).animate(
begin: 0.96, CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
end: 1, ),
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
child: child, child: child,
), ),
), ),
); );
} }
class _AvatarMenuContent extends StatefulWidget { class _StandaloneAvatarCard extends StatefulWidget {
const _AvatarMenuContent();
@override @override
State<_AvatarMenuContent> createState() => _AvatarMenuContentState(); State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState();
} }
class _AvatarMenuContentState extends State<_AvatarMenuContent> { class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> {
bool _expanded = false; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -60,226 +91,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>(); final settings = context.watch<SettingsController>();
final trashCount = context.watch<TrashController>().items.length; final trashCount = context.watch<TrashController>().items.length;
final hiddenTabs = settings.tabOrder void settingsTap() {
.where((t) => settings.hiddenTabs.contains(t)) onClose();
.toList();
void closeAndOpenSettings() {
Navigator.pop(context);
openSettings(context); openSettings(context);
} }
return Align( final header = InkWell(
alignment: Alignment.topCenter, onTap: onExpand,
// Just the status-bar inset, not the top bar's own height on top of child: Padding(
// it - the card covers the top bar (title included) rather than padding: const EdgeInsets.all(NooSpace.md),
// sitting below it, so opening it reads as the avatar growing into child: Row(
// this instead of a separate element appearing underneath the row children: [
// it came from. Expanded(
child: SafeArea( child: Column(
bottom: false, crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
// Same side inset as the Files list's rows (`NooSpace.sm`), so the Text(
// card is as wide as the list beneath it. session.username,
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), maxLines: 1,
child: SizedBox( overflow: TextOverflow.ellipsis,
width: double.infinity, style: NooText.bodyL.copyWith(
child: Container( fontWeight: FontWeight.w600,
// No `color`/`clipBehavior` here - a `Container` with both a color: colors.fg1,
// `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,
),
],
), ),
), ),
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<SettingsController>();
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<AnimationController> {
final _AvatarNavigationHostState _host;
AvatarNavigationScope._({
required _AvatarNavigationHostState host,
required super.child,
}) : _host = host,
super(notifier: host.animation);
static AvatarNavigationScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AvatarNavigationScope>();
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<SettingsController>().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<AvatarNavigationHost> createState() => _AvatarNavigationHostState();
}
class _AvatarNavigationHostState extends State<AvatarNavigationHost>
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<SettingsController>();
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<SettingsController>();
final session = context.watch<SessionController>();
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 { class _OtherAccountRow extends StatelessWidget {
final String name; final String name;
final String host; final String host;
@@ -333,7 +715,7 @@ class _OtherAccountRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
NooAvatar(initials: accountInitial(name), current: false, size: 40), NooAvatar(initials: accountInitial(name), current: false, size: 32),
], ],
), ),
), ),
+21 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import 'noo/nav/noo_bottom_bar.dart'; import 'noo/nav/noo_bottom_bar.dart';
import 'avatar_menu.dart';
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic /// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and /// [NooNavDestination] list, so `MainShellView` only deals in tabs and
@@ -23,6 +24,7 @@ class BottomNavBar extends StatelessWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
final VoidCallback? onSearchTap; final VoidCallback? onSearchTap;
final bool avatarInBottomBar;
const BottomNavBar({ const BottomNavBar({
super.key, super.key,
@@ -35,6 +37,7 @@ class BottomNavBar extends StatelessWidget {
required this.selectedIndex, required this.selectedIndex,
required this.onDestinationSelected, required this.onDestinationSelected,
this.onSearchTap, this.onSearchTap,
this.avatarInBottomBar = false,
}); });
@override @override
@@ -50,11 +53,27 @@ class BottomNavBar extends StatelessWidget {
NooNavDestination(icon: tab.icon, label: tab.label), NooNavDestination(icon: tab.icon, label: tab.label),
], ],
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onSelected: onDestinationSelected, onSelected: (index) {
AvatarNavigationScope.maybeOf(context)?.close();
onDestinationSelected(index);
},
searchDestination: onSearchTap == null searchDestination: onSearchTap == null
? null ? null
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'), : 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,
); );
} }
} }
+32 -30
View File
@@ -1,18 +1,11 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_controller.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
/// The blurred/translucent chrome for the media viewer's overlay bars /// Media viewer chrome that shares the navigation frost preference.
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video /// Turning frosting off produces an opaque themed surface without blur.
/// 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.
class FrostedGlassContainer extends StatelessWidget { class FrostedGlassContainer extends StatelessWidget {
final Widget child; final Widget child;
final double borderRadius; final double borderRadius;
@@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget {
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only). /// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
final BorderRadius? radius; final BorderRadius? radius;
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an /// Blur sigma for the backdrop filter. Defaults to the shared frost setting.
/// explicit value (e.g. from user settings) to make it adjustable. final double? blurSigma;
final double blurSigma;
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed /// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting.
/// value; pass an explicit value (e.g. from user settings) to make it final double? opacity;
/// adjustable. final bool? frosted;
final double opacity;
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at /// The tint under the blur. Defaults to [NooColors.surface] (resolved at
/// build time, so it always matches the active theme) - pass an explicit /// build time, so it always matches the active theme) - pass an explicit
@@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget {
required this.child, required this.child,
this.borderRadius = 0, this.borderRadius = 0,
this.radius, this.radius,
this.blurSigma = 20, this.blurSigma,
this.opacity = 0.8, this.opacity,
this.frosted,
this.color, this.color,
}); });
@@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shape = radius ?? BorderRadius.circular(borderRadius); final shape = radius ?? BorderRadius.circular(borderRadius);
final tint = color ?? context.nooColors.surface; final tint = color ?? context.nooColors.surface;
final settings = context.watch<SettingsController?>();
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( return ClipRRect(
borderRadius: shape, borderRadius: shape,
child: BackdropFilter( child: enabled
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), ? BackdropFilter(
child: Container( filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
decoration: BoxDecoration( child: fill,
color: tint.withValues(alpha: opacity), )
borderRadius: shape, : fill,
),
child: child,
),
),
); );
} }
} }
+31 -6
View File
@@ -50,6 +50,9 @@ class NooButton extends StatefulWidget {
/// are responsible for a tooltip/semantic label, since there's no text. /// are responsible for a tooltip/semantic label, since there's no text.
final bool iconOnly; final bool iconOnly;
/// Continuous icon-only to labelled expansion for animated shell actions.
final double? expansion;
const NooButton({ const NooButton({
super.key, super.key,
this.variant = NooButtonVariant.primary, this.variant = NooButtonVariant.primary,
@@ -60,6 +63,7 @@ class NooButton extends StatefulWidget {
this.disabled = false, this.disabled = false,
this.onTap, this.onTap,
this.iconOnly = false, this.iconOnly = false,
this.expansion,
}); });
@override @override
@@ -108,11 +112,22 @@ class _NooButtonState extends State<NooButton> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg), 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.icon != null && widget.child != null)
if (widget.child != null) SizedBox(width: s.gap * (widget.expansion ?? 1)),
DefaultTextStyle( if (widget.child != null && widget.expansion != 0)
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg), ClipRect(
child: widget.child!, 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<NooButton> {
width: widget.iconOnly width: widget.iconOnly
? s.height ? s.height
: (widget.fullWidth ? double.infinity : null), : (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.zero
: EdgeInsets.only( : EdgeInsets.only(
left: hasLeadingIcon ? s.px - 4 : s.px, left: hasLeadingIcon ? s.px - 4 : s.px,
+4 -1
View File
@@ -23,7 +23,8 @@ const int _kDesktopInlineActions = 4;
/// Replaces the bare row of Material `IconButton`s each screen used to /// Replaces the bare row of Material `IconButton`s each screen used to
/// build ad hoc. /// 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 /// 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 /// count, and the bulk actions. This sits in the *content* pane's own
/// pinned header slot on every platform (the shell's desktop toolbar, /// pinned header slot on every platform (the shell's desktop toolbar,
@@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.card), borderRadius: BorderRadius.circular(NooRadii.card),
border: Border.all(color: colors.line),
boxShadow: const [nooDialogShadow],
), ),
child: Row( child: Row(
children: [ children: [
+18 -4
View File
@@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget {
final IconData? icon; final IconData? icon;
final Widget label; final Widget label;
final Widget? subtitle; final Widget? subtitle;
/// Renders as `value` text + a trailing chevron - mutually exclusive with /// Renders as `value` text + a trailing chevron - mutually exclusive with
/// [trailing] (a `NooToggle`, button, badge, etc.). /// [trailing] (a `NooToggle`, button, badge, etc.).
final String? value; final String? value;
final Widget? trailing; final Widget? trailing;
final bool danger; final bool danger;
/// Accent-text action row, e.g. "Add account". /// Accent-text action row, e.g. "Add account".
final bool accent; final bool accent;
final VoidCallback? onTap; final VoidCallback? onTap;
final Color? backgroundColor;
const NooSettingsRow({ const NooSettingsRow({
super.key, super.key,
@@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget {
this.danger = false, this.danger = false,
this.accent = false, this.accent = false,
this.onTap, this.onTap,
this.backgroundColor,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; 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; final minHeight = subtitle != null ? 60.0 : 52.0;
Widget? trail = trailing; Widget? trail = trailing;
@@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget {
children: [ children: [
Text( Text(
value!, 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), const SizedBox(width: 4),
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3), Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
@@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget {
} }
return Material( return Material(
color: colors.surface, color: backgroundColor ?? colors.surface,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: ConstrainedBox( child: ConstrainedBox(
@@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
if (icon != null) ...[ 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), const SizedBox(width: 14),
], ],
Expanded( Expanded(
+161 -48
View File
@@ -19,8 +19,8 @@ const double _kAndroidPillHeight = 32;
/// what order, is the caller's business; this only draws [destinations]. /// what order, is the caller's business; this only draws [destinations].
/// ///
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top /// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
/// padding) of icon 24 over a 10px label. Active: accent-text, 600. /// padding) of icon 24 over a 10px selected label. Active: accent-text, 600.
/// Idle: fg-3, 500. No ripple. /// Idle: centered fg-3 icon, no label. No ripple.
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px /// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
/// icon over a 12px label (label only visible - not removed from layout, /// 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 /// 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 NooNavDestination? searchDestination;
final VoidCallback? onSearchTap; 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({ const NooBottomBar({
super.key, super.key,
required this.style, required this.style,
@@ -90,6 +95,8 @@ class NooBottomBar extends StatelessWidget {
required this.onSelected, required this.onSelected,
this.searchDestination, this.searchDestination,
this.onSearchTap, this.onSearchTap,
this.avatarSatellite,
this.avatarMenuProgress = 0,
}); });
/// The row height [build] draws for [style]/[barStyle] - exposed so a /// 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 // Attached folds Search into the row itself (last item); floating
// gives it a separate satellite circle instead (built below), so the // gives it a separate satellite circle instead (built below), so the
// row builders only ever see it as a trailing item in the former case. // 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 final row = ios
? _buildIosRow(trailingSearch: rowSearch) ? _buildIosRow(trailingSearch: rowSearch)
@@ -162,7 +174,9 @@ class NooBottomBar extends StatelessWidget {
16, 16,
NooBottomBar.floatingBottomMargin, NooBottomBar.floatingBottomMargin,
), ),
child: hasSearch child: avatarSatellite != null
? _withAvatar(pill, barHeight, hasSearch)
: hasSearch
// A fixed-height SizedBox, not just a Row with // A fixed-height SizedBox, not just a Row with
// crossAxisAlignment.stretch - the bottomNavigationBar slot // crossAxisAlignment.stretch - the bottomNavigationBar slot
// gives this widget a *loose* (unbounded-max) height // gives this widget a *loose* (unbounded-max) height
@@ -202,37 +216,126 @@ class NooBottomBar extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, 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; 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 Color _fill(NooColors colors) => frosted
? colors.surface.withValues(alpha: frostedOpacity) ? colors.surface.withValues(alpha: frostedOpacity)
: colors.surface; : 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}) { Widget _buildIosRow({NooNavDestination? trailingSearch}) {
return Row( return LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.stretch, builder: (context, constraints) {
children: [ final width =
for (var i = 0; i < destinations.length; i++) constraints.maxWidth /
Expanded( (destinations.length +
child: _IosItem( (trailingSearch == null ? 0 : _rowSearchFraction));
destination: destinations[i], return Row(
selected: i == selectedIndex, crossAxisAlignment: CrossAxisAlignment.stretch,
onTap: () => onSelected(i), children: [
), for (var i = 0; i < destinations.length; i++)
), SizedBox(
if (trailingSearch != null) width: width,
Expanded( child: _IosItem(
child: _IosItem( destination: destinations[i],
destination: trailingSearch, selected: i == selectedIndex,
selected: false, selectedLabelOnly: true,
onTap: onSearchTap!, 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, NooNavDestination? trailingSearch,
}) { }) {
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0); final itemCount =
destinations.length + (trailingSearch != null ? _rowSearchFraction : 0);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = constraints.maxWidth / itemCount; final itemWidth = constraints.maxWidth / itemCount;
@@ -268,7 +372,8 @@ class NooBottomBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (var i = 0; i < destinations.length; i++) for (var i = 0; i < destinations.length; i++)
Expanded( SizedBox(
width: itemWidth,
child: _AndroidItem( child: _AndroidItem(
destination: destinations[i], destination: destinations[i],
selected: i == selectedIndex, selected: i == selectedIndex,
@@ -277,13 +382,14 @@ class NooBottomBar extends StatelessWidget {
), ),
), ),
if (trailingSearch != null) if (trailingSearch != null)
Expanded( _searchRowItem(
child: _AndroidItem( _AndroidItem(
destination: trailingSearch, destination: trailingSearch,
selected: false, selected: false,
floating: floating, floating: floating,
onTap: onSearchTap!, onTap: onSearchTap!,
), ),
itemWidth,
), ),
], ],
), ),
@@ -362,11 +468,13 @@ class _SearchSatellite extends StatelessWidget {
class _IosItem extends StatelessWidget { class _IosItem extends StatelessWidget {
final NooNavDestination destination; final NooNavDestination destination;
final bool selected; final bool selected;
final bool selectedLabelOnly;
final VoidCallback onTap; final VoidCallback onTap;
const _IosItem({ const _IosItem({
required this.destination, required this.destination,
required this.selected, required this.selected,
this.selectedLabelOnly = false,
required this.onTap, required this.onTap,
}); });
@@ -377,30 +485,35 @@ class _IosItem extends StatelessWidget {
return Semantics( return Semantics(
button: true, button: true,
selected: selected, selected: selected,
label: selectedLabelOnly && !selected ? destination.label : null,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: Padding( child: selectedLabelOnly && !selected
padding: const EdgeInsets.only(top: 8), ? Center(child: Icon(destination.icon, size: 24, color: fg))
child: Column( : Padding(
mainAxisAlignment: MainAxisAlignment.start, padding: const EdgeInsets.only(top: 8),
children: [ child: Column(
Icon(destination.icon, size: 24, color: fg), mainAxisAlignment: MainAxisAlignment.start,
const SizedBox(height: 4), children: [
Text( Icon(destination.icon, size: 24, color: fg),
destination.label, const SizedBox(height: 4),
maxLines: 1, Text(
overflow: TextOverflow.ellipsis, destination.label,
style: NooText.navLabel.copyWith( maxLines: 1,
fontSize: 10, overflow: TextOverflow.ellipsis,
height: 1, style: NooText.navLabel.copyWith(
fontWeight: selected ? FontWeight.w600 : FontWeight.w500, fontSize: 10,
color: fg, height: 1,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
color: fg,
),
),
],
), ),
), ),
],
),
),
), ),
); );
} }
+2 -2
View File
@@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating }
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu` /// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu] /// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
/// drops that icon entirely and repurposes the avatar button - already /// drops that icon entirely and repurposes the avatar button - already
/// sitting top-right, already a tap target every user already knows - as /// available at the top or bottom by user preference - as the one entry
/// the one entry point instead, opening a dropdown anchored below it (see /// point instead, expanding into an anchored navigation card (see
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer /// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
/// sliding from the opposite edge. Applies on both platforms, since /// sliding from the opposite edge. Applies on both platforms, since
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not /// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
+51
View File
@@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on /// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
/// Android) through [IconTheme]. /// Android) through [IconTheme].
final List<Widget> actions; final List<Widget> actions;
final bool iosActionsInTitleRow;
/// iOS only: the search field shown below the large title. /// iOS only: the search field shown below the large title.
final Widget? search; final Widget? search;
@@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
this.leading, this.leading,
this.onMenu, this.onMenu,
this.actions = const [], this.actions = const [],
this.iosActionsInTitleRow = false,
this.search, this.search,
this.androidTitleTrailing, this.androidTitleTrailing,
this.backgroundColor, this.backgroundColor,
@@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
@override @override
Size get preferredSize { Size get preferredSize {
if (!_ios) return const Size.fromHeight(_androidRow); if (!_ios) return const Size.fromHeight(_androidRow);
if (iosActionsInTitleRow) {
return Size.fromHeight(
64 + (search != null ? _iosSearch + _iosSearchBottom : 0),
);
}
return Size.fromHeight( return Size.fromHeight(
_iosRow + _iosRow +
_iosTitleTop + _iosTitleTop +
@@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
} }
Widget _buildIos(NooColors colors, Widget? lead) { 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( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
+30
View File
@@ -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),
),
],
);
}
}
+34 -11
View File
@@ -4,8 +4,10 @@ import '../../models/selection_action.dart';
import '../../providers/settings_controller.dart'; import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/lists/noo_tab_order_row.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 '../noo/noo_layout.dart';
import 'settings_section.dart';
/// Settings section: the priority order bulk actions (favorite, share, /// Settings section: the priority order bulk actions (favorite, share,
/// download, ...) show in on the Files/Photos/Favorites multi-select /// 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 /// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
/// desktop, see its own doc comment) vs. behind its trailing "More" - /// 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 /// 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 { class SettingsActionBarSection extends StatelessWidget {
const SettingsActionBarSection({super.key}); const SettingsActionBarSection({super.key});
@@ -33,18 +35,30 @@ class SettingsActionBarSection extends StatelessWidget {
final desktop = NooLayout.isDesktop(context); final desktop = NooLayout.isDesktop(context);
final orderList = _ActionOrderList( final orderList = _ActionOrderList(
rounded: desktop,
order: settings.selectionActionOrder, order: settings.selectionActionOrder,
onReorder: (oldIndex, newIndex) => onReorder: (oldIndex, newIndex) =>
_handleReorder(settings, oldIndex, newIndex), _handleReorder(settings, oldIndex, newIndex),
); );
final positionRow = NooSettingsRow(
label: const Text('Position'),
trailing: NooSegmentedControl<SelectionBarPosition>(
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; final colors = context.nooColors;
if (!desktop) { if (!desktop) {
return SettingsSection( return NooGroupedList(
title: 'Action bar', footer: const Text(_subtitle),
subtitle: _subtitle, children: [positionRow, orderList],
children: [orderList],
); );
} }
@@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
const SizedBox(height: NooSpace.md), const SizedBox(height: NooSpace.md),
positionRow,
const SizedBox(height: NooSpace.md),
orderList, orderList,
], ],
), ),
@@ -79,8 +95,13 @@ class SettingsActionBarSection extends StatelessWidget {
class _ActionOrderList extends StatelessWidget { class _ActionOrderList extends StatelessWidget {
final List<SelectionActionKind> order; final List<SelectionActionKind> order;
final void Function(int oldIndex, int newIndex) onReorder; 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 @override
Widget build(BuildContext context) { 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( return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card), borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox( child: rows,
decoration: BoxDecoration(color: colors.line),
child: list,
),
); );
} }
} }
+54 -22
View File
@@ -56,9 +56,9 @@ class SettingsAppearanceSection extends StatelessWidget {
_BottomBarStyleRow(settings: settings), _BottomBarStyleRow(settings: settings),
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.glassWater, icon: LucideIcons.glassWater,
label: const Text('Frosted glass bottom bar'), label: const Text('Frosted glass'),
subtitle: const Text( subtitle: const Text(
'A translucent, blurred bottom bar - content scrolls behind it', 'Blurred bottom navigation, avatar menus and media viewer panels',
), ),
trailing: NooToggle( trailing: NooToggle(
checked: settings.bottomBarFrosted, checked: settings.bottomBarFrosted,
@@ -67,6 +67,23 @@ class SettingsAppearanceSection extends StatelessWidget {
), ),
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings), if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
_NavMenuStyleRow(settings: settings), _NavMenuStyleRow(settings: settings),
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
NooSettingsRow(
icon: LucideIcons.userRound,
label: const Text('Avatar position'),
trailing: NooSegmentedControl<AvatarPosition>(
onSurface: true,
value: settings.avatarPosition,
onChanged: settings.setAvatarPosition,
options: const [
NooSegmentOption(value: AvatarPosition.top, label: 'Top'),
NooSegmentOption(
value: AvatarPosition.bottom,
label: 'Bottom',
),
],
),
),
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.search, icon: LucideIcons.search,
label: const Text('Search in bottom bar'), label: const Text('Search in bottom bar'),
@@ -96,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget {
icon: LucideIcons.palette, icon: LucideIcons.palette,
label: const Text('Accent color'), label: const Text('Accent color'),
subtitle: Text( subtitle: Text(
settings.useDynamicColor settings.useDynamicColor &&
Theme.of(context).platform != TargetPlatform.iOS
? 'Matching your wallpaper' ? 'Matching your wallpaper'
: settings.seedColor == AppTheme.defaultAccent : settings.seedColor == AppTheme.defaultAccent
? 'Default' ? 'Default'
: 'Custom color', : 'Custom color',
), ),
trailing: _AccentSwatchDot( trailing: _AccentSwatchDot(
color: settings.useDynamicColor ? null : settings.seedColor, color:
settings.useDynamicColor &&
Theme.of(context).platform != TargetPlatform.iOS
? null
: settings.seedColor,
), ),
onTap: () => _openAccentPicker(context, settings), onTap: () => _openAccentPicker(context, settings),
), ),
@@ -116,15 +138,16 @@ class SettingsAppearanceSection extends StatelessWidget {
onChanged: settings.setAmoledDark, onChanged: settings.setAmoledDark,
), ),
), ),
NooSettingsRow( if (Theme.of(context).platform != TargetPlatform.iOS)
icon: LucideIcons.waves, NooSettingsRow(
label: const Text('Seek bar style'), icon: LucideIcons.waves,
subtitle: const Text( label: const Text('Seek bar style'),
'The progress bar style used when playing videos', 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), const SizedBox(height: 12),
NooSegmentedControl<ThemeMode>( NooSegmentedControl<ThemeMode>(
fill: true, fill: true,
onSurface: true,
value: settings.themeMode, value: settings.themeMode,
onChanged: settings.setThemeMode, onChanged: settings.setThemeMode,
options: const [ options: const [
@@ -231,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
NooSegmentedControl<NooBottomBarStyle>( NooSegmentedControl<NooBottomBarStyle>(
fill: true, fill: true,
onSurface: true,
value: settings.bottomBarStyle, value: settings.bottomBarStyle,
onChanged: settings.setBottomBarStyle, onChanged: settings.setBottomBarStyle,
options: const [ options: const [
@@ -287,6 +312,7 @@ class _FrostedGlassRow extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
NooSegmentedControl<FrostedGlassPreset?>( NooSegmentedControl<FrostedGlassPreset?>(
fill: true, fill: true,
onSurface: true,
// No segment selected once the sliders no longer match a preset. // No segment selected once the sliders no longer match a preset.
value: settings.frostedPreset, value: settings.frostedPreset,
onChanged: (preset) { onChanged: (preset) {
@@ -402,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
NooSegmentedControl<NooNavMenuStyle>( NooSegmentedControl<NooNavMenuStyle>(
fill: true, fill: true,
onSurface: true,
value: settings.navMenuStyle, value: settings.navMenuStyle,
onChanged: settings.setNavMenuStyle, onChanged: settings.setNavMenuStyle,
options: const [ options: const [
@@ -455,6 +482,7 @@ class _FabStyleRow extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
NooSegmentedControl<FabStyle>( NooSegmentedControl<FabStyle>(
fill: true, fill: true,
onSurface: true,
value: settings.fabStyle, value: settings.fabStyle,
onChanged: settings.setFabStyle, onChanged: settings.setFabStyle,
options: const [ options: const [
@@ -508,22 +536,26 @@ class _AccentSwatchDot extends StatelessWidget {
} }
void _openAccentPicker(BuildContext context, SettingsController settings) { void _openAccentPicker(BuildContext context, SettingsController settings) {
final useDynamicColor =
settings.useDynamicColor &&
Theme.of(context).platform != TargetPlatform.iOS;
Widget grid(NooColors colors) => Wrap( Widget grid(NooColors colors) => Wrap(
spacing: 14, spacing: 14,
runSpacing: 14, runSpacing: 14,
children: [ children: [
_AccentSwatch( if (Theme.of(context).platform != TargetPlatform.iOS)
isSelected: settings.useDynamicColor, _AccentSwatch(
onTap: () { isSelected: settings.useDynamicColor,
settings.setUseDynamicColor(true); onTap: () {
Navigator.pop(context); settings.setUseDynamicColor(true);
}, Navigator.pop(context);
background: colors.surface3, },
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20), background: colors.surface3,
), child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
),
for (final color in AppTheme.seedColors) for (final color in AppTheme.seedColors)
_AccentSwatch( _AccentSwatch(
isSelected: !settings.useDynamicColor && settings.seedColor == color, isSelected: !useDynamicColor && settings.seedColor == color,
onTap: () { onTap: () {
settings.setSeedColor(color); settings.setSeedColor(color);
Navigator.pop(context); Navigator.pop(context);
@@ -125,12 +125,16 @@ class _CacheIntervalRow extends StatelessWidget {
onChanged: (value) => onChanged(value.round()), 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(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Text( child: Text(
'While the app is closed, synced files refresh at most every ' Theme.of(context).platform == TargetPlatform.iOS
'15 min (an Android limit).', ? '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), style: NooText.meta.copyWith(color: colors.fg3),
), ),
), ),
+10 -3
View File
@@ -10,8 +10,13 @@ import '../theme/design_tokens.dart';
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final double height; final double height;
final Widget child; 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 @override
double get minExtent => height; double get minExtent => height;
@@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
// Colors.transparent` guards against the same M3 elevation-tint // Colors.transparent` guards against the same M3 elevation-tint
// behavior even if this ever gets a non-zero elevation. // behavior even if this ever gets a non-zero elevation.
return Material( return Material(
color: context.nooColors.bg, color: floating ? Colors.transparent : context.nooColors.bg,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
child: child, child: child,
); );
@@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
@override @override
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) { bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
return oldDelegate.height != height || oldDelegate.child != child; return oldDelegate.height != height ||
oldDelegate.child != child ||
oldDelegate.floating != floating;
} }
} }
+2 -8
View File
@@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart';
import '../frosted_glass_container.dart'; import '../frosted_glass_container.dart';
import 'viewer_icon_button.dart'; import 'viewer_icon_button.dart';
/// The bottom panel overlaid on the media viewer: an optional transport row /// A full-width media panel containing transport, actions and details.
/// ([transportControls], video only) above the action row /// Its solid/frosted surface follows the shared appearance preference.
/// (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.
class MediaActionBar extends StatelessWidget { class MediaActionBar extends StatelessWidget {
final bool isFavorite; final bool isFavorite;
final bool isBusy; final bool isBusy;
+63 -6
View File
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
@@ -198,12 +199,14 @@ class VideoTransportControls extends StatelessWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: WavySeekBar( child: Theme.of(context).platform == TargetPlatform.iOS
controller: controller, ? IosSeekBar(controller: controller, color: fg)
playedColor: fg, : WavySeekBar(
trackColor: fg.withValues(alpha: 0.3), controller: controller,
style: progressBarStyle, 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<IosSeekBar> createState() => _IosSeekBarState();
}
class _IosSeekBarState extends State<IosSeekBar> {
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 /// A Material You "expressive" wavy seek bar: the played portion of the
/// track animates as a travelling sine wave while playing and settles flat /// track animates as a travelling sine wave while playing and settles flat
/// when paused, matching the Android 16+ media player style. Drag or tap /// when paused, matching the Android 16+ media player style. Drag or tap
@@ -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<void>.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<void>.delayed(Duration.zero);
settings.setSelectionBarPosition(SelectionBarPosition.bottom);
await Future<void>.delayed(Duration.zero);
final restored = SettingsController();
await Future<void>.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<void>.delayed(Duration.zero);
expect(settings.avatarPosition, AvatarPosition.top);
settings.setAvatarPosition(AvatarPosition.bottom);
await Future<void>.delayed(Duration.zero);
final restored = SettingsController();
await Future<void>.delayed(Duration.zero);
expect(restored.avatarPosition, AvatarPosition.bottom);
settings.dispose();
restored.dispose();
},
);
}
+381 -10
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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/app_theme.dart';
import 'package:noo/theme/design_tokens.dart'; import 'package:noo/theme/design_tokens.dart';
import 'package:noo/widgets/noo/core/noo_avatar.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/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'; import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
/// Covers `SettingsController.navMenuStyle`'s two options as wired through /// Covers `SettingsController.navMenuStyle`'s two options as wired through
@@ -86,7 +90,24 @@ void main() {
Future<void> pumpTopBar( Future<void> pumpTopBar(
WidgetTester tester, { WidgetTester tester, {
required NooNavMenuStyle navMenuStyle, 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 { }) 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)); await tester.binding.setSurfaceSize(const Size(400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
@@ -110,15 +131,49 @@ void main() {
), ),
], ],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false), theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false)
home: Scaffold( .copyWith(
drawer: const Drawer(child: Text('drawer open')), platform: style == NooNavStyle.ios
appBar: AppTopBar( ? TargetPlatform.iOS
style: NooNavStyle.android, : TargetPlatform.android,
tab: AppTab.files, ),
navMenuStyle: navMenuStyle, home: Builder(
), builder: (context) {
body: const SizedBox(), 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`: // Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`:
// the active account's dependent controllers start periodic // the active account's dependent controllers start periodic
// timers/futures that would spin it forever. // 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)); await tester.pump(const Duration(milliseconds: 10));
} }
} }
@@ -224,4 +279,320 @@ void main() {
final screenWidth = tester.getSize(find.byType(MaterialApp)).width; final screenWidth = tester.getSize(find.byType(MaterialApp)).width;
expect(tester.getSize(row).width, screenWidth - 2 * NooSpace.sm - 4); 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<BackdropFilter>(backdrop).filter,
ImageFilter.blur(sigmaX: 19, sigmaY: 19),
);
final decorations = tester.widgetList<DecoratedBox>(
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<Material>(
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<Opacity>(
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);
});
} }
@@ -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<Container>(fill).decoration as BoxDecoration).color,
colors.surface,
);
settings.setBottomBarFrosted(true);
await tester.pump();
expect(
tester.widget<BackdropFilter>(find.byType(BackdropFilter)).filter,
ImageFilter.blur(sigmaX: 19, sigmaY: 19),
);
expect(
(tester.widget<Container>(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();
});
}
+74
View File
@@ -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<void> seekTo(Duration position) async {
sought = position;
value = value.copyWith(position: position);
}
@override
Future<void> pause() async {
value = value.copyWith(isPlaying: false);
}
@override
Future<void> 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<CupertinoSlider>(
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<CupertinoSlider>(find.byType(CupertinoSlider)).onChanged,
isNull,
);
await tester.pumpWidget(const SizedBox());
await controller.dispose();
});
}
+3 -1
View File
@@ -215,7 +215,7 @@ void main() {
expect(opacity.opacity, 0); expect(opacity.opacity, 0);
}); });
testNooWidgets('ios: unchanged icon-above-label layout, no indicator', ( testNooWidgets('ios: only selected tab has a label, no indicator', (
tester, tester,
theme, theme,
c, c,
@@ -238,6 +238,8 @@ void main() {
final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images)); final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images));
final labelCenter = tester.getCenter(find.text('Photos')); final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy)); 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', ( testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
@@ -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);
},
);
}
}
}