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