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