import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_progress_rail.dart'; import 'package:miler/Models/stop_status.dart'; /// The whole point of replacing the node rail is that route length no longer /// changes the widget's shape. A hub can assign 6 stops or 40; the summary at /// the top of Bookings has to look the same either way. void main() { Widget app(List progress) { return ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripProgressRail( progress: progress, slotLabel: '10:00 AM – 1:00 PM', remaining: const Duration(minutes: 130), ), ), ), ), ); } Future pump(WidgetTester tester, List progress) async { tester.view.physicalSize = const Size(1170, 2532); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget(app(progress)); await tester.pumpAndSettle(); } List route( int done, int skipped, int pending, { bool current = true, }) { return [ for (var i = 0; i < done; i++) StopProgress.done, if (current) StopProgress.current, for (var i = 0; i < skipped; i++) StopProgress.skipped, for (var i = 0; i < pending; i++) StopProgress.pending, ]; } testWidgets('height does not grow with the number of stops', (tester) async { await pump(tester, route(2, 1, 3)); final short = tester.getSize(find.byType(TripProgressRail)).height; await pump(tester, route(8, 3, 28)); // 40 stops final long = tester.getSize(find.byType(TripProgressRail)).height; expect( long, short, reason: 'the summary must not stretch or squeeze with route length', ); }); testWidgets('never overflows, even on a long route', (tester) async { await pump(tester, route(20, 5, 60)); // 86 stops expect(tester.takeException(), isNull); }); // Position, skips and time left are ONE line now, not two ends of a Row. // They are one sentence about one trip, and splitting them left a gap in the // middle wide enough to read as two unrelated facts. testWidgets('reports position, skips and the count', (tester) async { await pump(tester, route(4, 2, 11)); // 18 stops, current is #5 expect(find.text('4 of 18 done'), findsOneWidget); expect( find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then hub'), findsOneWidget, ); }); testWidgets('drops the skip clause when nothing was skipped', (tester) async { await pump(tester, route(3, 0, 5)); expect(find.text('On stop 4 of 9 · 2h 10m left, then hub'), findsOneWidget); }); // The card, its border, its shadow and its padding are gone — the rail sits // straight on the page above the stop list. This is the assertion that keeps // it that way: a future "let's give it an edge" change has to come here first. testWidgets('draws no box of its own', (tester) async { await pump(tester, route(4, 2, 11)); final boxes = tester .widgetList( find.descendant( of: find.byType(TripProgressRail), matching: find.byType(Container), ), ) .map((c) => c.decoration) .whereType(); for (final d in boxes) { // ── A ring on a node is a mark; a border on a rectangle is a box ── // // The rule this protects is that the rail sits bare on the page — no // card, no panel, no outline around the run. Circular nodes are exempt // and always were in spirit: the route timeline draws hollow rings for // exactly the same reason, and the rail's two framing nodes (where the // rider is, where his day ends) are hollow so a place he does not work // never competes with one he does. if (d.shape == BoxShape.circle) continue; expect(d.border, isNull, reason: 'no bordered container inside the rail'); expect( d.boxShadow, anyOf(isNull, isEmpty), reason: 'no elevation inside the rail', ); } }); // ── A run of one is a journey, not a badge ── // // A rider with a single accepted order met one red circled **1** at the top // of Deliveries: a counter, or a notification badge, but not a route. The rail // is framed by the two places the run actually goes between — where he is // standing, and where the day ends — so even one stop reads as // "here → this pickup → done". testWidgets('one stop still reads as a journey', (tester) async { await pump(tester, route(1, 0, 0)); expect( find.byIcon(LucideIcons.locateFixed), findsOneWidget, reason: 'the run starts where the rider is', ); // Parcel is the default profile in these tests: its day ends at the depot. expect(find.byIcon(LucideIcons.warehouse), findsOneWidget); expect(find.text('1'), findsOneWidget); }); testWidgets('a milk run ends at home, not at a warehouse', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); await pump(tester, route(1, 0, 0)); expect(find.byIcon(LucideIcons.house), findsOneWidget); expect(find.byIcon(LucideIcons.warehouse), findsNothing); }); testWidgets('a finished route says head back to the hub', (tester) async { await pump(tester, route(6, 0, 0, current: false)); expect(find.text('All stops done'), findsOneWidget); expect(find.text('Head back to the hub'), findsOneWidget); }); // The rail numbers the stops the same way the list below it does, and the // finished ones are the coloured ones. This is the whole point of the nodes. testWidgets('draws a numbered node per stop, and one scooter', ( tester, ) async { await pump(tester, route(2, 0, 2)); // 5 stops, current is #3 // Every stop keeps its number, including the one he is on — the scooter // rides above the node rather than replacing its digit. for (final n in ['1', '2', '3', '4', '5']) { expect(find.text(n), findsOneWidget, reason: 'stop $n is on the rail'); } expect(find.byIcon(LucideIcons.bike), findsOneWidget); }); // The point of the overlay: finishing a stop *moves* the marker, visibly, // instead of repainting two circles. testWidgets('the scooter rides forward when a stop is completed', ( tester, ) async { Finder scooter() => find.byIcon(LucideIcons.bike); await pump(tester, route(2, 0, 2)); // on stop 3 of 5 final from = tester.getCenter(scooter()).dx; // Not `pump()` — that settles, and the whole assertion is about the frames // in between. await tester.pumpWidget(app(route(3, 0, 1))); // stop 3 done, now on 4 await tester.pump(const Duration(milliseconds: 120)); final moving = tester.getCenter(scooter()).dx; await tester.pumpAndSettle(); final to = tester.getCenter(scooter()).dx; expect(to, greaterThan(from), reason: 'it advanced one stop'); expect(moving, greaterThan(from)); expect(moving, lessThan(to), reason: 'it travelled rather than jumped'); }); testWidgets('a finished route parks the scooter', (tester) async { await pump(tester, route(2, 0, 3, current: false)); // 5 stops, none active // Nothing is `current`, so the first pending stop holds the scooter. expect(find.byIcon(LucideIcons.bike), findsOneWidget); await pump(tester, route(5, 0, 0, current: false)); // all done expect( find.byIcon(LucideIcons.bike), findsNothing, reason: 'nothing left to ride to', ); }); // Long routes scroll rather than shrink — the failure that killed the first // version of this rail was nodes being squeezed to fit a fixed width. testWidgets('a long route scrolls instead of crushing its nodes', ( tester, ) async { await pump(tester, route(4, 0, 35)); // 40 stops expect(find.byType(SingleChildScrollView), findsWidgets); expect(tester.takeException(), isNull); }); testWidgets('the old per-stop hub labels are gone', (tester) async { await pump(tester, route(2, 0, 4)); expect(find.text('START'), findsNothing); expect(find.text('RETURN'), findsNothing); }); // ── The scrolling edge has to read as "more route", not as a clip ── // // A 21-stop round draws about seven nodes on a 390pt phone and sliced the // eighth down its middle at the viewport edge. A half circle stopping in mid // air reads as a rendering fault, and it is the one edge that has to invite // the push that reveals the rest of the day. testWidgets('a scrolling rail fades at its edges; a short one does not', ( tester, ) async { await pump(tester, route(4, 0, 35)); // 40 stops — scrolls expect( find.descendant( of: find.byType(TripProgressRail), matching: find.byType(ShaderMask), ), findsOneWidget, reason: 'the rail continues past the edge and must say so', ); // Four. The rail reserves a framing node at each end and holds a 22pt // floor under the gaps, so against a 390pt phone's 358pt of content width // only six nodes — four stops — lay out without scrolling. Which is worth // knowing on its own: the fade is not a long-route embellishment, it is // what almost every real round gets. await pump(tester, route(1, 0, 2)); // 4 stops — fits expect( find.descendant( of: find.byType(TripProgressRail), matching: find.byType(ShaderMask), ), findsNothing, reason: 'a route that fits ends where it ends — fading it would hint at ' 'work that does not exist', ); }); // ── The rung → rail mapping, in both legs' vocabulary ── // // `stopProgressFor` is read on the Deliveries tab, where every stop is past // `pickup-complete` by definition. It used to ask `isActive || arrived` — // both pickup-leg rungs — so the customer's door the rider was standing at // matched nothing and drew grey. group('stopProgressFor', () { tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); test('the door the rider is at is where the scooter goes', () { ServiceProfile.setActive(ServiceProfile.milkMan); expect( stopProgressFor(StopStatus.deliveryArrived), StopProgress.current, reason: 'he is standing at it — this is the whole question the rail ' 'answers', ); }); test('the pickup leg still resolves', () { ServiceProfile.setActive(ServiceProfile.milkMan); expect(stopProgressFor(StopStatus.arrived), StopProgress.current); expect(stopProgressFor(StopStatus.active), StopProgress.current); }); test('a carried bag is not a delivered one', () { ServiceProfile.setActive(ServiceProfile.milkMan); // `picked` and `outForDelivery` are both "aboard, not handed over". They // are ahead of the rider, not behind him — see `picked_not_active_test`. expect(stopProgressFor(StopStatus.picked), StopProgress.pending); expect(stopProgressFor(StopStatus.outForDelivery), StopProgress.pending); expect(stopProgressFor(StopStatus.delivered), StopProgress.done); }); test('done is line-aware', () { ServiceProfile.setActive(ServiceProfile.parcel); expect( stopProgressFor(StopStatus.picked), StopProgress.done, reason: 'collection IS the job on logistics', ); ServiceProfile.setActive(ServiceProfile.milkMan); expect( stopProgressFor(StopStatus.picked), StopProgress.pending, reason: 'on a round the same rung is the middle of the morning', ); }); test('a skipped stop keeps its own state', () { ServiceProfile.setActive(ServiceProfile.milkMan); expect(stopProgressFor(StopStatus.skipped), StopProgress.skipped); }); }); }