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

This commit is contained in:
Ayushya Amitabh
2026-10-06 23:18:50 -04:00
parent c5b46fd493
commit ce2273c9a6
39 changed files with 2309 additions and 647 deletions
+15 -7
View File
@@ -65,7 +65,8 @@ new provider instance):
without a circular dependency.
- [`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
+12 -5
View File
@@ -1021,10 +1021,10 @@ backup) with state in `<...>/sync-state/<accountId>/*.json`.
pruning uses `rmdir` and only on a successful empty read; a root's etag
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
View File
@@ -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,17 +89,8 @@ 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))
}
}
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(
+34 -4
View File
@@ -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)
+45 -5
View File
@@ -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,13 +432,27 @@ class _MainShellViewState extends State<MainShellView> {
// null`).
final tabStack = Stack(
children: [
IndexedStack(
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(
@@ -444,11 +460,14 @@ class _MainShellViewState extends State<MainShellView> {
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),
);
}
}
+37 -1
View File
@@ -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;
+2 -2
View File
@@ -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 -3
View File
@@ -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,9 +526,11 @@ class _FavoritesViewState extends State<FavoritesView> {
final contentSlivers = <Widget>[
if (widget.topBar != null) topBarSliver(widget.topBar!),
if (!selectionAtBottom)
SliverPersistentHeader(
pinned: !_isSelecting,
pinned: true,
delegate: StickyHeaderDelegate(
floating: _isSelecting,
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, selectedItems)
@@ -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,6 +675,10 @@ class _FavoritesViewState extends State<FavoritesView> {
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SelectionBarOverlay(
bar: selectionAtBottom
? _buildSelectionBar(context, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
@@ -676,6 +701,7 @@ class _FavoritesViewState extends State<FavoritesView> {
),
),
),
),
);
}
+1 -3
View File
@@ -428,9 +428,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// The stage follows the app's own theme (`bg`) rather than being pinned
// 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(
+24 -4
View File
@@ -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,9 +862,11 @@ 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.
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
@@ -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(
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,6 +973,10 @@ class _FilesViewState extends State<FilesView> {
browser.navigateUp();
}
},
child: SelectionBarOverlay(
bar: selectionAtBottom
? _buildSelectionBar(context, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
@@ -994,6 +1013,7 @@ class _FilesViewState extends State<FilesView> {
),
),
),
),
);
}
+51 -11
View File
@@ -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,9 +260,11 @@ 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.
if (!selectionAtBottom)
SliverPersistentHeader(
pinned: !_isSelecting,
pinned: true,
delegate: StickyHeaderDelegate(
floating: _isSelecting,
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, pick, selectedItems)
@@ -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,6 +388,10 @@ class _PhotosViewState extends State<PhotosView> {
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SelectionBarOverlay(
bar: selectionAtBottom
? _buildSelectionBar(context, pick, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
@@ -390,6 +414,7 @@ class _PhotosViewState extends State<PhotosView> {
),
),
),
),
);
}
@@ -554,18 +579,33 @@ class _PhotosViewState extends State<PhotosView> {
),
),
const SizedBox(height: 22),
NooGroupedList(
children: [
NooSettingsRow(
FilterSection(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
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(
value: photos.hiddenFilter,
+13 -2
View File
@@ -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,
);
}
}
+68 -8
View File
@@ -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(
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,
tooltip: 'Create',
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),
);
},
),
ShellAvatarButton(
),
),
if (navMenuStyle != NooNavMenuStyle.avatarMenu ||
avatarPosition == AvatarPosition.top)
AvatarNavigationAnchor(
child: Builder(
builder: (anchorContext) => ShellAvatarButton(
hitBox: _ios ? 44 : 48,
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
? () => showAvatarMenu(context)
? () => showAvatarMenu(anchorContext)
: 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;
}
+531 -149
View File
@@ -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,89 +91,20 @@ 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),
final header = InkWell(
onTap: onExpand,
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.username,
@@ -158,44 +120,35 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
serverHost(session.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
color: colors.fg3,
),
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,
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,
),
],
),
),
),
AnimatedSize(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: Alignment.topCenter,
child: _expanded
);
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)
@@ -204,17 +157,12 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
Navigator.pop(context);
onClose();
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
NooSpace.md,
),
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
spacing: 8,
children: [
@@ -223,16 +171,12 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(
context,
);
nav.pop();
final nav = Navigator.of(context);
onClose();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(
isAddingAccount: true,
),
const LoginView(isAddingAccount: true),
),
);
},
@@ -242,7 +186,7 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: closeAndOpenSettings,
onTap: settingsTap,
),
),
],
@@ -251,11 +195,14 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
],
)
: const SizedBox(width: double.infinity),
),
if (hiddenTabs.isNotEmpty) ...[
Divider(height: 1, color: colors.line),
for (final tab in hiddenTabs)
);
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
@@ -265,25 +212,460 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
)
: null,
onTap: () {
Navigator.pop(context);
onClose();
settings.requestTab(tab);
},
),
],
Divider(height: 1, color: colors.line),
NooSettingsRow(
backgroundColor: Colors.transparent,
icon: LucideIcons.settings,
label: const Text('Settings'),
onTap: closeAndOpenSettings,
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),
),
),
),
),
),
);
}
}
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,
),
),
),
),
],
],
);
},
),
);
},
),
);
}
@@ -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),
],
),
),
+21 -2
View File
@@ -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,
);
}
}
+30 -28
View File
@@ -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;
return ClipRRect(
borderRadius: shape,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: Container(
final settings = context.watch<SettingsController?>();
final enabled = frosted ?? settings?.bottomBarFrosted ?? false;
final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20;
final fill = Container(
decoration: BoxDecoration(
color: tint.withValues(alpha: opacity),
color: enabled
? tint.withValues(
alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72,
)
: tint,
borderRadius: shape,
),
child: child,
),
),
);
return ClipRRect(
borderRadius: shape,
child: enabled
? BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: fill,
)
: fill,
);
}
}
+29 -4
View File
@@ -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,12 +112,23 @@ 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(
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,
+4 -1
View File
@@ -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: [
+18 -4
View File
@@ -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(
+127 -14
View File
@@ -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,38 +216,127 @@ 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 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++)
Expanded(
SizedBox(
width: width,
child: _IosItem(
destination: destinations[i],
selected: i == selectedIndex,
selectedLabelOnly: true,
onTap: () => onSelected(i),
),
),
if (trailingSearch != null)
Expanded(
child: _IosItem(
_searchRowItem(
_IosItem(
destination: trailingSearch,
selected: false,
selectedLabelOnly: true,
onTap: onSearchTap!,
),
width,
),
],
);
},
);
}
Widget _buildAndroidRow(
@@ -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,10 +485,13 @@ 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(
child: selectedLabelOnly && !selected
? Center(child: Icon(destination.icon, size: 24, color: fg))
: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
@@ -394,7 +505,9 @@ class _IosItem extends StatelessWidget {
style: NooText.navLabel.copyWith(
fontSize: 10,
height: 1,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
color: fg,
),
),
+2 -2
View File
@@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating }
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
/// 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
+51
View File
@@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
/// 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,
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// Pins selection actions above the shell navigation. Scaffold supplies its
/// navigation footprint through MediaQuery padding when the body extends
/// behind navigation; otherwise the body already ends above it.
class SelectionBarOverlay extends StatelessWidget {
static const clearance = 64.0;
final Widget child;
final Widget? bar;
const SelectionBarOverlay({super.key, required this.child, this.bar});
@override
Widget build(BuildContext context) {
if (bar == null) return child;
return Stack(
fit: StackFit.expand,
children: [
child,
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.paddingOf(context).bottom + 8,
child: SizedBox(height: 56, child: bar),
),
],
);
}
}
+34 -11
View File
@@ -4,8 +4,10 @@ import '../../models/selection_action.dart';
import '../../providers/settings_controller.dart';
import '../../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 {
);
},
);
return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
final rows = DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: list,
),
);
if (!rounded) return rows;
return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: rows,
);
}
}
+37 -5
View File
@@ -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,6 +138,7 @@ class SettingsAppearanceSection extends StatelessWidget {
onChanged: settings.setAmoledDark,
),
),
if (Theme.of(context).platform != TargetPlatform.iOS)
NooSettingsRow(
icon: LucideIcons.waves,
label: const Text('Seek bar style'),
@@ -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,10 +536,14 @@ 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: [
if (Theme.of(context).platform != TargetPlatform.iOS)
_AccentSwatch(
isSelected: settings.useDynamicColor,
onTap: () {
@@ -523,7 +555,7 @@ void _openAccentPicker(BuildContext context, SettingsController settings) {
),
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,11 +125,15 @@ 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 '
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 -3
View File
@@ -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;
}
}
+2 -8
View File
@@ -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;
+58 -1
View File
@@ -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,7 +199,9 @@ class VideoTransportControls extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: WavySeekBar(
child: Theme.of(context).platform == TargetPlatform.iOS
? IosSeekBar(controller: controller, color: fg)
: WavySeekBar(
controller: controller,
playedColor: fg,
trackColor: fg.withValues(alpha: 0.3),
@@ -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();
},
);
}
+377 -6
View File
@@ -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(
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: NooNavStyle.android,
tab: AppTab.files,
style: style,
tab: tab,
uploadButtonStyle: uploadStyle,
navMenuStyle: navMenuStyle,
avatarPosition: position,
searchInBottomBar: search,
),
body: const SizedBox(),
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();
});
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/viewer/media_video_preview.dart';
import 'package:video_player/video_player.dart';
import 'noo/noo_test_utils.dart';
class _Controller extends VideoPlayerController {
_Controller() : super.networkUrl(Uri.parse('https://example.com/video.mp4'));
Duration? sought;
@override
Future<void> seekTo(Duration position) async {
sought = position;
value = value.copyWith(position: position);
}
@override
Future<void> pause() async {
value = value.copyWith(isPlaying: false);
}
@override
Future<void> play() async {
value = value.copyWith(isPlaying: true);
}
}
void main() {
setUpNooTests();
for (final playing in [false, true]) {
testWidgets('iOS scrubbing preserves playing=$playing', (tester) async {
final controller = _Controller();
controller.value = VideoPlayerValue(
duration: const Duration(seconds: 60),
position: const Duration(seconds: 15),
isInitialized: true,
isPlaying: playing,
);
await pumpNoo(
tester,
IosSeekBar(controller: controller, color: CupertinoColors.white),
);
final slider = tester.widget<CupertinoSlider>(
find.byType(CupertinoSlider),
);
expect(slider.value, 0.25);
slider.onChangeStart!(0.25);
expect(controller.value.isPlaying, false);
slider.onChanged!(0.5);
await tester.pump();
expect(controller.sought, const Duration(seconds: 30));
slider.onChangeEnd!(0.5);
await tester.pump();
expect(controller.value.isPlaying, playing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await controller.dispose();
});
}
testWidgets('iOS scrubber disables seeking before duration is available', (
tester,
) async {
final controller = _Controller();
await pumpNoo(
tester,
IosSeekBar(controller: controller, color: CupertinoColors.white),
);
expect(
tester.widget<CupertinoSlider>(find.byType(CupertinoSlider)).onChanged,
isNull,
);
await tester.pumpWidget(const SizedBox());
await controller.dispose();
});
}
+3 -1
View File
@@ -215,7 +215,7 @@ void main() {
expect(opacity.opacity, 0);
});
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);
},
);
}
}
}