Files
noo/test/widgets/tabs/tab_state_slivers_test.dart
T
ayushyaandClaude Sonnet 5 5dea9038f7 Make the shell top bar hide/reveal with scroll, per tab
Each regular tab (Files/Photos/Favorites/Shares/Recent/Activity/Trash)
now plants its own AppTopBar as the first sliver in its own
CustomScrollView, via a new topBarSliver() helper (tab_state_slivers.dart)
wrapping the framework's SliverFloatingHeader, instead of one shared
instance living in Scaffold.appBar.

This gives each tab's top bar Material's native floating-app-bar
behavior - tied to that tab's own ScrollController - scrolling away as
the list scrolls down and reappearing the moment the drag reverses,
not only once scrolled all the way back to the top, which a single
fixed Scaffold.appBar (or the "put it in a SliverToBoxAdapter" escape
hatch NooTopBar's doc comment already mentioned) can't do. It sits
above each tab's own pinned in-content sticky header (the sort/filter
row, or the selection bar that replaces it), which keeps working
unchanged underneath it.

main.dart builds a separate, correctly-labelled AppTopBar per tab
(rather than one for "whichever tab is selected") and passes it through
buildAppTabView's new optional topBar parameter; it's null on desktop
(which keeps its own NooToolbar) and while picking, matching the old
Scaffold.appBar: pickRequest == null guard.

SliverFloatingHeader sizes itself from the bar's own natural layout
(like SliverToBoxAdapter) rather than a fixed extent declared up front,
so NooTopBar's own internal SafeArea keeps handling the status-bar inset
correctly with no extra height math needed on this side.

Adds a widget test (test/widgets/tabs/tab_state_slivers_test.dart)
driving a raw gesture mid-drag to verify the reveal-follows-finger
behavior, since a single tester.drag() only pumps after the gesture -
including release - has already finished, which doesn't exercise the
live per-frame reveal this change depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:28:36 -04:00

144 lines
5.0 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);
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,
);
},
);
}