first commit
This commit is contained in:
269
test/bottom_nav_indicator_test.dart
Normal file
269
test/bottom_nav_indicator_test.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user