Files
noo/test/widgets/tabs/tab_state_slivers_test.dart
ayushyaandClaude Sonnet 5 9f47d0a3e3 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>
2026-09-30 22:59:19 -04:00

254 lines
8.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/tabs/tab_state_slivers.dart';
import '../noo/noo_test_utils.dart';
/// A minimal stand-in for `AppTopBar` - just needs to be a fixed-height
/// [PreferredSizeWidget], same contract `topBarSliver` wraps in real tabs.
class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget {
const _FakeTopBar();
static const double height = 56;
@override
Size get preferredSize => const Size.fromHeight(height);
@override
Widget build(BuildContext context) => const SizedBox(
height: height,
child: ColoredBox(color: Colors.blue),
);
}
/// Matches the one sliver `topBarSliver` inserts, even once it's fully
/// scrolled away - `find.byType`'s default `skipOffstage: true` treats a
/// sliver with `geometry.visible == false` (paintExtent 0, exactly the
/// "fully hidden" state part of this test exercises) as offstage and
/// excludes it, which would otherwise make `tester.renderObject` throw
/// instead of reporting the 0 this test expects. A function, not a
/// top-level constant, so each call re-evaluates against whichever widget
/// 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(
theme: nooTheme(Brightness.light),
home: Scaffold(
body: CustomScrollView(
controller: controller,
slivers: [
topBarSliver(const _FakeTopBar()),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
SizedBox(height: 60, child: Text('Item $index')),
childCount: 40,
),
),
],
),
),
),
);
}
void main() {
setUpNooTests();
testWidgets(
'topBarSliver floats away scrolling down and reappears the moment the '
'drag reverses, not only once back at the top (Material floating app '
'bar behavior)',
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await _pumpHost(tester, controller);
// The render object's own `geometry.paintExtent` is the ground truth
// for how much of the bar is actually visible - more reliable than
// inferring visibility from hit-testing/finding the child, which a
// floating header sliver keeps built regardless of paint extent.
double paintExtent() =>
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent;
// Fully visible before any scroll.
expect(paintExtent(), _FakeTopBar.height);
expect(controller.offset, 0);
// Drive one continuous drag by hand (rather than `tester.drag`, which
// only pumps once the whole gesture - including the final pointer-up
// - has already finished): the floating header's reveal-while-
// dragging behavior keys off the *live* scroll direction as reported
// mid-gesture, so this needs an intermediate pump while the pointer
// is still down to actually observe it.
final gesture = await tester.startGesture(const Offset(200, 300));
// Drag up (content scrolls down) well past the bar's own height, so
// it scrolls fully away, same as a plain `SliverToBoxAdapter` would.
await gesture.moveBy(const Offset(0, -200));
await tester.pump();
expect(paintExtent(), 0);
final scrolledOffset = controller.offset;
expect(
scrolledOffset,
greaterThan(_FakeTopBar.height),
reason: "the drag should have moved past the bar's own height",
);
// Reverse direction by a small amount, still mid-gesture and nowhere
// near the top of the list. A plain `SliverToBoxAdapter`, or a header
// that only reappears once back at the top, would stay fully hidden
// here. The floating bar should start reappearing immediately
// instead, following the drag.
await gesture.moveBy(const Offset(0, 40));
await tester.pump();
expect(controller.offset, lessThan(scrolledOffset));
expect(
controller.offset,
greaterThan(_FakeTopBar.height / 2),
reason: 'still far from the top of the list',
);
expect(
paintExtent(),
greaterThan(0),
reason:
'the bar should already be reappearing on the first upward '
'movement, without needing to reach the top',
);
await gesture.up();
await tester.pumpAndSettle();
},
);
testWidgets(
"topBarSliver sizes itself from the wrapped bar's own layout (e.g. its "
'SafeArea-padded height), not a value declared up front',
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await _pumpHost(tester, controller);
expect(
tester.getSize(find.byType(_FakeTopBar)).height,
_FakeTopBar.height,
);
},
);
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,
);
},
);
}