Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s
Build APK / build (push) Successful in 5m50s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user