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