Fix status-bar overlap on scroll, and reposition/elevate the avatar menu

- Every tab now wraps its CustomScrollView in SafeArea(top: true) - the
  pinned sort/filter row was riding up underneath the status bar once
  the floating top bar above it fully collapsed, since that bar's own
  internal SafeArea only reserved the inset while it had some height
  left to put it in. Reserving it outside the scrolling region entirely
  fixes this regardless of the floating header's state. Caught live on
  device and backed by a new regression test.
- The avatar menu now covers the top bar (title included) instead of
  sitting below it - positioned off just the status-bar inset rather
  than the top bar's full height on top of that.
- The avatar menu's card now carries a second, tighter contact shadow
  alongside nooDialogShadow - that shadow alone was essentially
  invisible on a small card over a dark theme's near-black background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:59:19 -04:00
co-authored by Claude Sonnet 5
parent e6d96a6f9f
commit 9f47d0a3e3
12 changed files with 280 additions and 75 deletions
+12 -4
View File
@@ -297,13 +297,21 @@ bare system prompt gives no context on its own.
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
in-content header (the sort/filter controls row, or Files/Photos'
selection bar - see below); the two float/scroll independently.
`BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
selection bar - see below); the two float/scroll independently - each
tab also wraps its whole `CustomScrollView` in `SafeArea(top: true,
bottom: false, ...)` so that pinned header stays clear of the status bar
once the floating top bar above it fully collapses (see
`topBarSliver`'s own doc comment for why that reservation can't live
inside the top bar itself). `BottomNavBar` (`widgets/bottom_nav_bar.dart`)
adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
link (opens Settings - there's no in-page anchor to scroll to its Tabs
section yet).
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.
- **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
+10 -1
View File
@@ -236,7 +236,16 @@ blocks are noted where they matter:
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. Also added `NooTopBar.androidTitleReplacement`: Android has no
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. Also added `NooTopBar.androidTitleReplacement`: 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 search isn't in the bottom bar) replaces the
+8 -1
View File
@@ -100,12 +100,19 @@ class ActivityView extends StatelessWidget {
...tabBottomInsetSlivers(context),
];
final scrollView = CustomScrollView(
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating top bar
// above it fully collapses.
final scrollView = SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item feed.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
);
return ColoredBox(
+7
View File
@@ -658,6 +658,12 @@ class _FavoritesViewState extends State<FavoritesView> {
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating
// top bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
@@ -667,6 +673,7 @@ class _FavoritesViewState extends State<FavoritesView> {
),
),
),
),
);
}
+18 -2
View File
@@ -853,17 +853,33 @@ class _FilesViewState extends State<FilesView> {
unawaited(sync.syncOnPull());
return browser.reload();
},
// `topBarSliver`'s floating header can collapse all the way to
// zero height (fully scrolled away), at which point the sticky
// controls row right below it in `contentSlivers` would otherwise
// ride up underneath the status bar instead of stopping below it
// - the floating top bar used to be the only thing reserving that
// space (via its own internal `SafeArea`), and that reservation
// disappears along with it once it's fully hidden. Wrapping the
// whole scroll view keeps the inset outside the scrolling region
// entirely, so it's never implicated in the floating header's own
// collapse/reveal math - safe to apply unconditionally, since
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
// status bar there).
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// Pull-to-refresh needs a scroll physics that allows dragging
// past the edge even when content doesn't fill the viewport -
// an empty or single-item list otherwise can't be pulled at all
// under the platform default physics.
// an empty or single-item list otherwise can't be pulled at
// all under the platform default physics.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
),
);
}
+7
View File
@@ -369,6 +369,12 @@ class _PhotosViewState extends State<PhotosView> {
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating
// top bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
@@ -378,6 +384,7 @@ class _PhotosViewState extends State<PhotosView> {
),
),
),
),
);
}
+7
View File
@@ -88,6 +88,12 @@ class _RecentViewState extends State<RecentView> {
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: recent.fetchAll,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating top
// bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
@@ -96,6 +102,7 @@ class _RecentViewState extends State<RecentView> {
slivers: contentSlivers,
),
),
),
);
}
+7
View File
@@ -134,6 +134,12 @@ class _SharesViewState extends State<SharesView> {
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: sharesController.fetchAll,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating top
// bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
@@ -142,6 +148,7 @@ class _SharesViewState extends State<SharesView> {
slivers: contentSlivers,
),
),
),
);
}
+7
View File
@@ -103,6 +103,12 @@ class _TrashViewState extends State<TrashView> {
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: trashController.fetchAll,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating top
// bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
@@ -111,6 +117,7 @@ class _TrashViewState extends State<TrashView> {
slivers: contentSlivers,
),
),
),
);
}
+26 -23
View File
@@ -9,8 +9,6 @@ import '../theme/design_tokens.dart';
import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_badge.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/nav/noo_top_bar.dart';
import 'noo/noo_layout.dart';
import 'shell/shell_common.dart';
/// The dropdown [ShellAvatarButton] opens when
@@ -24,14 +22,13 @@ import 'shell/shell_common.dart';
/// 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) {
final navStyle = NooLayout.navStyle(context);
return showGeneralDialog<void>(
context: context,
barrierColor: Colors.transparent,
barrierDismissible: true,
barrierLabel: 'Close menu',
transitionDuration: NooMotion.fast,
pageBuilder: (context, _, _) => _AvatarMenuContent(navStyle: navStyle),
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
transitionBuilder: (context, animation, _, child) => FadeTransition(
opacity: animation,
child: ScaleTransition(
@@ -47,9 +44,7 @@ Future<void> showAvatarMenu(BuildContext context) {
}
class _AvatarMenuContent extends StatelessWidget {
final NooNavStyle navStyle;
const _AvatarMenuContent({required this.navStyle});
const _AvatarMenuContent();
@override
Widget build(BuildContext context) {
@@ -60,15 +55,6 @@ class _AvatarMenuContent extends StatelessWidget {
final hiddenTabs = settings.tabOrder
.where((t) => settings.hiddenTabs.contains(t))
.toList();
// The row/avatar it opens from is always this tall + the status bar
// above it (the button that opens this can't be tapped while its own
// top bar is scrolled away, so it's always on-screen at this exact
// position when that happens) - matches `NooTopBar.preferredSize`
// exactly rather than a guessed constant.
final topBarHeight = NooTopBar(
style: navStyle,
title: '',
).preferredSize.height;
void closeAndOpenSettings() {
Navigator.pop(context);
@@ -77,15 +63,15 @@ class _AvatarMenuContent extends StatelessWidget {
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(
padding: EdgeInsets.fromLTRB(
NooSpace.md,
topBarHeight,
NooSpace.md,
0,
),
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md),
child: SizedBox(
width: double.infinity,
child: Container(
@@ -94,7 +80,24 @@ class _AvatarMenuContent extends StatelessWidget {
color: colors.surface,
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(NooRadii.card),
boxShadow: const [nooDialogShadow],
// `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: Material(
color: Colors.transparent,
+20 -3
View File
@@ -133,14 +133,31 @@ List<Widget> tabBottomInsetSlivers(BuildContext context) => [
/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout
/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up
/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc
/// comment) already accounts for the status-bar inset correctly, with no
/// extra height math needed here (unlike building this on the general-
/// purpose `SliverPersistentHeader` would have required).
/// comment) already accounts for the status-bar inset correctly while
/// [topBar] itself is visible, with no extra height math needed here
/// (unlike building this on the general-purpose `SliverPersistentHeader`
/// would have required).
///
/// Sits above a tab's own pinned in-content header (built with
/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection
/// bar that replaces it) - put this sliver first in `contentSlivers` so
/// that header stays exactly where it already is, independent of whether
/// [topBar] is currently shown or scrolled away.
///
/// That pinned header needs its OWN protection from the status bar too,
/// though: [topBar]'s `SafeArea` only reserves space while [topBar] has
/// some height to put it in - once it's fully collapsed (0 height, [topBar]
/// scrolled all the way away), that reservation disappears with it, and
/// the pinned header would ride up underneath the status bar instead of
/// stopping below it (a real bug this shipped with once already - caught
/// by `tab_state_slivers_test.dart`'s regression test for it). Every tab
/// view wraps its whole `CustomScrollView` (this sliver, the pinned header,
/// and everything else) in `SafeArea(top: true, bottom: false, ...)` to
/// fix this - that reserves the inset outside the scrolling/collapsing
/// region entirely, so it's never implicated in this sliver's own
/// collapse math regardless of [topBar]'s current state. Flutter's
/// `SafeArea` nesting means this doesn't double the inset: the outer one
/// zeroes `MediaQuery.padding.top` for everything below it, so [topBar]'s
/// own inner `SafeArea` sees nothing left to add.
Widget topBarSliver(PreferredSizeWidget topBar) =>
SliverFloatingHeader(child: topBar);
@@ -32,6 +32,38 @@ class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget {
/// tree is currently pumped.
Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false);
/// A minimal stand-in for each tab's own pinned sort/filter row
/// (`StickyHeaderDelegate`) - just needs to be a `SliverPersistentHeader`
/// with `pinned: true` below `topBarSliver`, same contract every real tab
/// view uses.
class _FakeStickyHeader extends StatelessWidget {
const _FakeStickyHeader();
static const double height = 48;
@override
Widget build(BuildContext context) => const SizedBox(
height: height,
child: ColoredBox(color: Colors.red),
);
}
class _FakeStickyHeaderDelegate extends SliverPersistentHeaderDelegate {
@override
double get minExtent => _FakeStickyHeader.height;
@override
double get maxExtent => _FakeStickyHeader.height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) => const _FakeStickyHeader();
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
}
Future<void> _pumpHost(WidgetTester tester, ScrollController controller) {
return tester.pumpWidget(
MaterialApp(
@@ -140,4 +172,82 @@ void main() {
);
},
);
testWidgets(
'a pinned header below topBarSliver stays clear of the status bar once '
'the floating bar fully collapses (regression: it used to ride up '
'underneath the status bar once the only thing reserving that space '
"disappeared along with the bar's own height)",
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
const statusBarHeight = 40.0;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(top: statusBarHeight),
),
child: MaterialApp(
theme: nooTheme(Brightness.light),
home: Scaffold(
// The fix under test: wrapping the scroll view (not just the
// top bar) in `SafeArea(top: true)` reserves the status-bar
// inset outside the scrolling/collapsing region entirely, so
// it's never implicated in `topBarSliver`'s own collapse math.
body: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: controller,
slivers: [
topBarSliver(const _FakeTopBar()),
SliverPersistentHeader(
pinned: true,
delegate: _FakeStickyHeaderDelegate(),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
SizedBox(height: 60, child: Text('Item $index')),
childCount: 40,
),
),
],
),
),
),
),
),
);
// Fully visible before any scroll - right below the reserved inset.
expect(
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
_FakeTopBar.height + statusBarHeight,
);
// Scroll well past the top bar's own height so it collapses fully.
final gesture = await tester.startGesture(const Offset(200, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
await gesture.up();
await tester.pump();
expect(
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent,
0,
reason:
'the top bar should be fully collapsed for this check to '
'mean anything',
);
// The regression: without the fix, this would be 0 (or negative,
// scrolled up under the status bar) instead of sitting right at the
// reserved inset.
expect(
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
statusBarHeight,
);
},
);
}