Files
doormile_milderapp/test/bottom_nav_compact_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

222 lines
8.0 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);
});
}