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