import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; /// The floating nav bar used to slide off the bottom of the screen as the rider /// moved down a list and come back when he reversed. That buys 66pt of list and /// costs him the ability to change tab at all until he scrolls the other way — /// on a long Activity list the only route out is a gesture he has to know /// about. A control that vanishes is not a smaller control; it is a missing one. /// /// It compacts instead: labels collapse, icons draw in, the pill narrows and /// shortens, and every target stays on screen. This is Instagram's iOS tab bar. /// /// `BottomPage` builds four real tab pages (Get controllers, SharedPreferences, /// Geolocator, live fetches) and cannot be pumped in a unit test, so — as in /// `card_stripe_layout_test.dart` — these pin the *idiom* rather than the /// widget. Two halves, only one of them obvious: /// /// • A `NotificationListener` accumulates `scrollDelta` /// from `ScrollUpdateNotification`. Deliberately not `UserScrollNotification`: /// that fires when a drag *begins*, when `metrics.pixels` has barely moved, /// so a distance threshold measured there compares an offset against itself /// and never fires at all. /// /// • The label collapses via `Align(heightFactor:)` inside a `ClipRect`, not a /// fade. Fading alone leaves the label's height behind, so the bar cannot /// actually get shorter — it shrinks around a blank line. void main() { const barKey = ValueKey('nav'); const labelKey = ValueKey('label'); const double resting = 66; const double compactHeight = 46; /// The shell shape, with the same wiring `BottomPage` uses. Widget shell({ required ValueNotifier compact, required ValueNotifier accum, bool scrollable = true, }) { return MaterialApp( home: ValueListenableBuilder( valueListenable: compact, builder: (context, k, _) => Scaffold( body: NotificationListener( onNotification: (n) { if (n.metrics.axis != Axis.vertical) return false; if (n.metrics.maxScrollExtent <= 0) { accum.value = 0; compact.value = 0; return false; } if (n is! ScrollUpdateNotification) return false; final delta = n.scrollDelta ?? 0; if (delta == 0) return false; accum.value = (accum.value * delta > 0) ? accum.value + delta : delta; if (delta > 0 && compact.value == 0 && accum.value > 24) { accum.value = 0; compact.value = 1; } else if (delta < 0 && compact.value == 1 && accum.value < -6) { accum.value = 0; compact.value = 0; } return false; }, child: ListView( children: [SizedBox(height: scrollable ? 3000 : 10)], ), ), bottomNavigationBar: SizedBox( key: barKey, height: resting + (compactHeight - resting) * k, child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.home_rounded, size: 21), ClipRect( child: Align( alignment: Alignment.topCenter, heightFactor: (1 - k).clamp(0.0, 1.0), child: Opacity( opacity: (1 - k * 1.8).clamp(0.0, 1.0), child: const Text('Home', key: labelKey), ), ), ), ], ), ), ), ), ); } double barHeight(WidgetTester tester) => tester.getSize(find.byKey(barKey)).height; testWidgets('scrolling down shrinks the bar; scrolling back restores it', (tester) async { final compact = ValueNotifier(0.0); final accum = ValueNotifier(0.0); addTearDown(compact.dispose); addTearDown(accum.dispose); await tester.pumpWidget(shell(compact: compact, accum: accum)); expect(barHeight(tester), resting); // Finger bottom → up: the list scrolls forward, the bar draws in. await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); expect(barHeight(tester), compactHeight); // Finger top → down: he is heading back, so it returns to full size. await tester.drag(find.byType(ListView), const Offset(0, 300)); await tester.pumpAndSettle(); expect(barHeight(tester), resting); }); testWidgets('the bar never leaves — the rider can always change tab', (tester) async { // This is the whole reason the behaviour changed. Whatever the rider does // with his thumb, the tab targets stay on screen and stay tappable. final compact = ValueNotifier(0.0); final accum = ValueNotifier(0.0); addTearDown(compact.dispose); addTearDown(accum.dispose); await tester.pumpWidget(shell(compact: compact, accum: accum)); for (final drag in const [-300.0, -600.0, -1200.0]) { await tester.drag(find.byType(ListView), Offset(0, drag)); await tester.pumpAndSettle(); expect(barHeight(tester), greaterThanOrEqualTo(compactHeight), reason: 'the bar must never collapse past its compact height'); expect(find.byType(Icon), findsOneWidget, reason: 'and its icons must stay on screen'); } }); testWidgets('the label collapses rather than merely fading', (tester) async { // A fade alone leaves the label's height behind, so the bar would shrink // around a blank line and never reach its compact height. final compact = ValueNotifier(0.0); final accum = ValueNotifier(0.0); addTearDown(compact.dispose); addTearDown(accum.dispose); await tester.pumpWidget(shell(compact: compact, accum: accum)); final labelled = tester.getSize(find.byKey(labelKey)).height; expect(labelled, greaterThan(0)); await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); // The Align is what gives the room back; the Text keeps its own size and is // clipped, so measuring the Text would report the same height either way. final align = tester.getSize( find.ancestor(of: find.byKey(labelKey), matching: find.byType(Align)).first, ); expect(align.height, lessThan(1), reason: 'the label must hand its height back, not just go invisible'); }); testWidgets('a page too short to scroll never compacts the bar', (tester) async { final compact = ValueNotifier(0.0); final accum = ValueNotifier(0.0); addTearDown(compact.dispose); addTearDown(accum.dispose); await tester .pumpWidget(shell(compact: compact, accum: accum, scrollable: false)); await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); expect(barHeight(tester), resting); }); testWidgets('a horizontal scroll leaves the bar alone', (tester) async { // Tab strips and carousels scroll sideways; the nav bar is not theirs. final compact = ValueNotifier(0.0); addTearDown(compact.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: NotificationListener( onNotification: (n) { if (n.metrics.axis != Axis.vertical) return false; compact.value = 1; return false; }, child: ListView( scrollDirection: Axis.horizontal, children: [Container(width: 3000)], ), ), ), ), ); await tester.drag(find.byType(ListView), const Offset(-300, 0)); await tester.pumpAndSettle(); expect(compact.value, 0); }); }