Files
doormile_milderapp/test/bottom_nav_compact_test.dart
2026-08-11 13:16:33 +05:30

206 lines
7.9 KiB
Dart

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<ScrollNotification>` 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<double> compact,
required ValueNotifier<double> accum,
bool scrollable = true,
}) {
return MaterialApp(
home: ValueListenableBuilder<double>(
valueListenable: compact,
builder: (context, k, _) => Scaffold(
body: NotificationListener<ScrollNotification>(
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<ScrollNotification>(
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);
});
}