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 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 = []; 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 _lit(WidgetTester tester) => tester .widgetList<_Tab>(find.byType(_Tab)) .map((w) => double.parse(w.lit.toStringAsFixed(3))) .toList(); class _PillBar extends StatefulWidget { final ValueNotifier 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 _curve; late Tween _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( begin: widget.initial.toDouble(), end: widget.initial.toDouble(), ); widget.selected.addListener(_onSelect); } void _onSelect() { _tween = Tween(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(); }