Files
doormile_milderapp/test/bottom_nav_indicator_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

291 lines
9.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// The selection pill travels between tabs instead of teleporting.
///
/// ── The bug ──
///
/// The pill was part of each tab: an `AnimatedContainer` per item whose fill
/// went from transparent to red over 280ms. Going Account → Home therefore faded
/// a pill out on the far right while a *second* pill faded in on the far left.
/// Nothing ever occupied the space between them, so there was no way for it to
/// pass over Bookings and Earnings — the highlight simply appeared at the
/// destination.
///
/// There is now one pill, owned by the bar, positioned by an animated value in
/// tab units. These tests measure where it actually is, frame by frame.
///
/// ── Why it is replicated rather than pumped ──
///
/// As in `bottom_nav_autohide_test.dart`: `BottomPage` builds four real tab
/// pages (Get controllers, SharedPreferences, Geolocator, live fetches) and
/// cannot be pumped headless. So this pins the *mechanism* — the same
/// `Stack` + `PositionedDirectional(start: slot * pos)` + single interrupted
/// `Tween`, at the same 380ms and `easeInOutCubic` — and asserts the properties
/// the real bar has to have.
void main() {
const pillKey = ValueKey('pill');
const tabs = 4;
const barWidth = 400.0;
const slot = barWidth / tabs;
const duration = Duration(milliseconds: 380);
/// Mirrors `_BottomPageState`'s pill wiring.
Widget bar({required ValueNotifier<int> selected, required int initial}) {
return MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: barWidth,
height: 66,
child: _PillBar(
key: const ValueKey('bar'),
selected: selected,
initial: initial,
pillKey: pillKey,
tabs: tabs,
duration: duration,
),
),
),
),
);
}
double pillLeft(WidgetTester tester) =>
tester.getTopLeft(find.byKey(pillKey)).dx -
tester.getTopLeft(find.byKey(const ValueKey('bar'))).dx;
testWidgets('at rest the pill sits on the selected tab', (tester) async {
for (final start in [0, 1, 2, 3]) {
final selected = ValueNotifier(start);
addTearDown(selected.dispose);
// Tear the tree down between iterations: the bar carries a fixed key, so
// pumping straight over it keeps the old State and `initial` — read once
// in `initState` — would never be re-applied.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpWidget(bar(selected: selected, initial: start));
await tester.pumpAndSettle();
expect(
pillLeft(tester),
closeTo(slot * start, 0.5),
reason: 'tab $start should be the one wearing the pill',
);
}
});
testWidgets('Account → Home crosses Earnings and Bookings on the way', (
tester,
) async {
// The failing case the rider described, measured directly.
final selected = ValueNotifier(3);
addTearDown(selected.dispose);
await tester.pumpWidget(bar(selected: selected, initial: 3));
await tester.pumpAndSettle();
expect(pillLeft(tester), closeTo(slot * 3, 0.5));
selected.value = 0;
await tester.pump();
// Sample the whole 380ms.
final samples = <double>[];
for (var elapsed = 0; elapsed < 400; elapsed += 20) {
await tester.pump(const Duration(milliseconds: 20));
samples.add(pillLeft(tester));
}
// It must actually have been over Earnings (slot 2) and Bookings (slot 1)
// at some point. A pair of cross-fading per-tab pills produces *no* sample
// in either band — the whole point of the test.
bool over(int tab) => samples.any(
(x) => x > slot * tab - slot / 2 && x < slot * tab + slot / 2,
);
expect(
over(2),
isTrue,
reason: 'the pill never passed over Earnings; samples: $samples',
);
expect(
over(1),
isTrue,
reason: 'the pill never passed over Bookings; samples: $samples',
);
// And it went there directly — no backtracking, which would read as a wobble.
for (var i = 1; i < samples.length; i++) {
expect(
samples[i],
lessThanOrEqualTo(samples[i - 1] + 0.5),
reason: 'the pill moved backwards at sample $i: $samples',
);
}
await tester.pumpAndSettle();
expect(pillLeft(tester), closeTo(0, 0.5), reason: 'it must arrive on Home');
});
testWidgets('a tap mid-slide resumes from where the pill is', (tester) async {
// Rebuilding the tween from `selected` rather than from the pill's live
// position would snap it back to Account and re-run the entire journey.
final selected = ValueNotifier(3);
addTearDown(selected.dispose);
await tester.pumpWidget(bar(selected: selected, initial: 3));
await tester.pumpAndSettle();
selected.value = 0;
await tester.pump();
await tester.pump(const Duration(milliseconds: 190));
final midway = pillLeft(tester);
expect(midway, greaterThan(slot * 0.5));
expect(midway, lessThan(slot * 2.5));
// Change your mind, back towards Account.
selected.value = 2;
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(
pillLeft(tester),
closeTo(midway, slot * 0.25),
reason: 'it should carry on from $midway, not jump back to ${slot * 3}',
);
await tester.pumpAndSettle();
expect(pillLeft(tester), closeTo(slot * 2, 0.5));
});
testWidgets('the tabs being crossed light up as it passes', (tester) async {
// The tint is derived from the pill's distance, not from `selected`. With a
// boolean, Home turned red on the tap while the pill was still two tabs
// away — the destination lighting up before the thing travelling to it
// arrives is most of what made the old version feel disconnected.
final selected = ValueNotifier(3);
addTearDown(selected.dispose);
await tester.pumpWidget(bar(selected: selected, initial: 3));
await tester.pumpAndSettle();
// Only Account is lit at rest.
expect(_lit(tester), [0.0, 0.0, 0.0, 1.0]);
selected.value = 0;
await tester.pump();
var sawMiddleGlow = false;
for (var elapsed = 0; elapsed < 400; elapsed += 20) {
await tester.pump(const Duration(milliseconds: 20));
final lit = _lit(tester);
if (lit[1] > 0.05 || lit[2] > 0.05) sawMiddleGlow = true;
}
expect(
sawMiddleGlow,
isTrue,
reason: 'Bookings and Earnings should warm up under the passing pill',
);
await tester.pumpAndSettle();
expect(_lit(tester), [1.0, 0.0, 0.0, 0.0]);
});
}
/// The `lit` value each tab is rendering, read back off the widget tree.
List<double> _lit(WidgetTester tester) => tester
.widgetList<_Tab>(find.byType(_Tab))
.map((w) => double.parse(w.lit.toStringAsFixed(3)))
.toList();
class _PillBar extends StatefulWidget {
final ValueNotifier<int> selected;
final int initial;
final Key pillKey;
final int tabs;
final Duration duration;
const _PillBar({
super.key,
required this.selected,
required this.initial,
required this.pillKey,
required this.tabs,
required this.duration,
});
@override
State<_PillBar> createState() => _PillBarState();
}
class _PillBarState extends State<_PillBar>
with SingleTickerProviderStateMixin {
late final AnimationController _anim;
late final Animation<double> _curve;
late Tween<double> _tween;
double get _pos => _tween.evaluate(_curve);
@override
void initState() {
super.initState();
_anim = AnimationController(
vsync: this,
duration: widget.duration,
value: 1.0,
);
_curve = CurvedAnimation(parent: _anim, curve: Curves.easeInOutCubic);
_tween = Tween<double>(
begin: widget.initial.toDouble(),
end: widget.initial.toDouble(),
);
widget.selected.addListener(_onSelect);
}
void _onSelect() {
_tween = Tween<double>(begin: _pos, end: widget.selected.value.toDouble());
_anim.forward(from: 0);
}
@override
void dispose() {
widget.selected.removeListener(_onSelect);
_anim.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (context, _) {
final pos = _pos;
return LayoutBuilder(
builder: (context, box) {
final slot = box.maxWidth / widget.tabs;
return Stack(
children: [
PositionedDirectional(
start: slot * pos,
top: 0,
bottom: 0,
width: slot,
child: Container(key: widget.pillKey, color: Colors.red),
),
Row(
children: List.generate(widget.tabs, (i) {
final t = (1.0 - (pos - i).abs()).clamp(0.0, 1.0);
return Expanded(child: _Tab(lit: t * t * t));
}),
),
],
);
},
);
},
);
}
}
class _Tab extends StatelessWidget {
final double lit;
const _Tab({required this.lit});
@override
Widget build(BuildContext context) => const SizedBox.expand();
}