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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user