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

270 lines
9.1 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();
}