first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/accepted_pill.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// The way from Home to the work used to be a "Continue trip" button under the
/// last hub row of a trip — so it only existed once that trip had nothing left
/// to decide, and it was below the fold on any route longer than about four
/// stops. The pill replaces it: same fixed place every time, and it says how
/// many bookings the rider is holding.
void main() {
Future<void> pump(WidgetTester tester, int count, {VoidCallback? onTap}) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: AcceptedPill(count: count, onTap: onTap ?? () {}),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('says nothing when nothing is accepted', (tester) async {
await pump(tester, 0);
expect(find.textContaining('accepted'), findsNothing);
});
testWidgets('carries the count and the singular wording', (tester) async {
await pump(tester, 1);
expect(find.text('1'), findsOneWidget);
expect(find.text('booking accepted'), findsOneWidget);
});
testWidgets('pluralises', (tester) async {
await pump(tester, 4);
expect(find.text('4'), findsOneWidget);
expect(find.text('bookings accepted'), findsOneWidget);
});
testWidgets('is a tap target that leads somewhere', (tester) async {
var taps = 0;
await pump(tester, 3, onTap: () => taps++);
await tester.tap(find.text('bookings accepted'));
await tester.pumpAndSettle();
expect(taps, 1);
// Material's touch-target floor. The pill is small on purpose — it is a way
// through, not the action Home exists for — so this is the guard that keeps
// "small" from becoming "hard to hit at arm's length".
final height = tester.getSize(find.byType(InkWell)).height;
expect(height, greaterThanOrEqualTo(48.0));
expect(ButtonSizes.minTapTarget, lessThanOrEqualTo(height));
});
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/homepage_banner.dart';
import 'package:miler/widget/Bottom_page.dart';
/// The live strip at the foot of Home and Bookings.
///
/// ── The bug being pinned ──
///
/// Both pages pinned it to `bottom: 0`, and the nav bar covered it. The bar
/// *floats* — a `Positioned` pill inside the shell's `Stack` rather than a
/// `bottomNavigationBar` — so the Scaffold reserves no room for it and
/// `SafeArea` cannot see it. Nothing in the type system says "you are about to
/// draw underneath the navigation", which is why it survived on two screens at
/// once, so it is measured here instead.
void main() {
const safeBottom = 34.0; // a home-indicator phone, where it is worst
Future<void> pump(
WidgetTester tester, {
double width = 390,
double textScale = 1.0,
int count = 1,
String address = '19, Thadagam Road, Venkitapuram, Coimbatore 641025',
}) async {
tester.view.physicalSize = Size(width * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(bottom: safeBottom),
textScaler: TextScaler.linear(textScale),
),
child: child!,
),
home: Scaffold(
body: Stack(
children: [
const Positioned.fill(child: ColoredBox(color: Colors.white)),
// Exactly how both pages mount it.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ActivePickupBanner(
activePickups: [
for (var i = 0; i < count; i++)
{
'orderid': 'ORD${1000 + i}',
'pickupaddress': address,
'orderstatus': 'arrived',
},
],
onTap: (_) {},
),
),
],
),
),
),
),
);
// Not `pumpAndSettle`: the LIVE dot pulses forever by design, so nothing on
// this widget ever settles. Two pumps past the longest transition is enough.
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
}
/// Gap between the bottom of the visible card and the bottom of the screen.
double gapBelowCard(WidgetTester tester) {
final screen = tester.view.physicalSize.height / tester.view.devicePixelRatio;
return screen - tester.getRect(find.byType(Material).last).bottom;
}
testWidgets('clears the floating nav bar while the bar is showing',
(tester) async {
await pump(tester);
final context = tester.element(find.byType(ActivePickupBanner));
final required = BottomPage.bottomInset(context);
expect(gapBelowCard(tester), greaterThanOrEqualTo(required),
reason: 'the card must sit above the nav bar, which claims '
'${required.toStringAsFixed(1)}pt at the foot of the page. It was '
'pinned to bottom: 0 and the bar covered it.');
});
testWidgets('sits at one height rather than chasing the bar', (tester) async {
// The banner used to follow a `navVisible` notifier down when the bar slid
// off on scroll and back up when it returned. The bar compacts now instead
// of leaving, so there is no state in which that room is free — and a
// banner that re-laid itself out on every change of scroll direction was
// moving for a 20pt gap the rider would never have noticed.
await pump(tester);
final resting = gapBelowCard(tester);
await tester.pump(const Duration(milliseconds: 400));
expect(gapBelowCard(tester), closeTo(resting, 0.5));
final context = tester.element(find.byType(ActivePickupBanner));
expect(resting, greaterThanOrEqualTo(BottomPage.bottomInset(context)),
reason: 'it must clear the bar at its resting height, which is the '
'height the bar returns to the moment he scrolls back up');
});
testWidgets('lines up with the nav bar rather than running full width',
(tester) async {
await pump(tester);
final card = tester.getRect(find.byType(Material).last);
// The nav bar sits on a 16 gutter; the banner stacks directly under it.
expect(card.left, closeTo(16.w, 1));
expect(390 - card.right, closeTo(16.w, 1));
});
group('lays out without overflowing', () {
for (final width in [320.0, 360.0, 390.0, 430.0]) {
for (final scale in [1.0, 1.3, 1.5, 2.0]) {
testWidgets('at ${width.toInt()}pt / ${scale}x', (tester) async {
// A long address plus a plural title is the widest this row ever gets,
// and the title used to sit unflexed beside a fixed-width LIVE chip.
await pump(
tester,
width: width,
textScale: scale,
count: 3,
address: '19/4B, Thadagam Main Road, Venkitapuram, Coimbatore, '
'Tamil Nadu 641025',
);
expect(tester.takeException(), isNull,
reason: 'the banner overflowed at ${width.toInt()}pt / ${scale}x');
});
}
}
});
testWidgets('says nothing when there is nothing running', (tester) async {
await pump(tester, count: 0);
expect(find.text('Active Pickup'), findsNothing);
expect(find.text('LIVE'), findsNothing);
});
testWidgets('names the count and the stop status', (tester) async {
await pump(tester, count: 3);
expect(find.text('3 Active Pickups'), findsOneWidget);
expect(find.textContaining('At the stop'), findsOneWidget,
reason: 'the rider should see how far into the stop he already is. The '
'wording comes from StopStatusX.label, the one place the app names '
'its statuses — and it is "At the stop" rather than the banner\'s '
'old private "At pickup", which was wrong on a delivery');
});
}

View File

@@ -0,0 +1,199 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/Dashboard/activity/activity_page.dart';
import 'package:miler/views/Dashboard/home/homepage.dart';
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ── A day's work is a list, not a stack of cards ──
///
/// Every finished stop was a filled, rounded card with 12pt of air under it,
/// and inside each one a *second*, white panel holding the three figures. So the
/// page came down the screen as grey slabs with white slabs punched into them —
/// the card-inside-card shape, repeated once per stop — and on top of that every
/// row opened with a 40pt green disc that was identical on all of them.
///
/// Finished stops are all the same kind of thing, arriving in one order, read by
/// scanning down. That is a list, and a list is separated by rules.
void main() {
String day(DateTime t) =>
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
'${t.day.toString().padLeft(2, '0')}';
Map<String, dynamic> finished(
String id,
String name,
String address, {
required String type,
double cash = 0,
int minsAgo = 30,
}) {
final now = DateTime.now();
final at = now.subtract(Duration(minutes: minsAgo));
return {
'orderid': id,
'type': type,
'pickupcustomer': name,
'pickupaddress': address,
'orderstatus': 'picked',
'completedday': day(now),
'completedat': at.toIso8601String(),
'startedat': at.subtract(const Duration(minutes: 14)).toIso8601String(),
if (cash > 0) 'collectionamt': '$cash',
'proof': {if (cash > 0) 'amountcollected': cash},
};
}
Future<void> pumpActivity(WidgetTester tester) async {
SharedPreferences.setMockInitialValues({
'userId': 1,
'completed_bookings': jsonEncode([
finished(
'DM240817',
'Sundar Textiles',
'78, Cross Cut Road, Gandhipuram, Coimbatore',
type: 'combined',
cash: 2450,
minsAgo: 22,
),
finished(
'DM240816',
'Green Leaf Grocers',
'29, Kovaipudur Main Road, Coimbatore',
type: 'delivery',
minsAgo: 68,
),
]),
});
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => const MaterialApp(home: ActivityPage()),
),
);
await tester.pumpAndSettle(const Duration(seconds: 2));
}
testWidgets('a finished stop is not a card', (tester) async {
await pumpActivity(tester);
expect(find.text('Sundar Textiles'), findsOneWidget);
// `cardSurface` was the row fill and `pureSurface` the fill of the figure
// panel nested inside it. Neither may come back: on a white page a filled
// row is a box round a list item, and a filled panel inside a filled row is
// the shape this page was rebuilt to remove.
final fills = tester
.widgetList<Container>(find.byType(Container))
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.map((d) => d.color)
.toList();
expect(fills, isNot(contains(ColorConstants.cardSurface)));
});
testWidgets('the figures line up across rows, not just within one', (
tester,
) async {
await pumpActivity(tester);
// The point of three fixed columns is comparing one stop against the next.
// While every row sat in its own padded card that only held *inside* a row;
// sharing the page margin is what makes column two of one stop sit directly
// above column two of the one below.
final captions = tester.widgetList<Text>(find.text('COLLECTED')).toList();
expect(captions.length, 2);
final rects = [
for (var i = 0; i < 2; i++) tester.getRect(find.text('COLLECTED').at(i)),
];
expect(rects[0].left, closeTo(rects[1].left, 0.5));
// Column one starts flush with the stop name above it — no card inset.
final time = tester.getRect(find.text('TIME').first);
final name = tester.getRect(find.text('Sundar Textiles'));
expect(time.left, closeTo(name.left, 0.5));
});
testWidgets('the stop type is a dotted word, not a filled tag', (
tester,
) async {
await pumpActivity(tester);
// Two tinted rectangles under every address — the type and the order id —
// were the loudest things on a row whose subject is the figures below them.
expect(find.text('Pickup & Delivery'), findsOneWidget);
// `P&D` is `chipLabel`, which only ever reached this page through the
// filled tag. (`badgeLabel` still appears inside the collapsed detail,
// which `AnimatedCrossFade` builds either way — that one is a field value,
// not a mark.)
expect(find.text('P&D'), findsNothing);
final label = tester.widget<Text>(find.text('Pickup & Delivery'));
expect(label.style!.color, ColorConstants.combinedAccent);
// The reference stays copyable, in grey, without claiming any rank.
final id = tester.widget<Text>(find.text('#DM240817'));
expect(id.style!.color, ColorConstants.secondaryText);
});
testWidgets('column captions are readable, not disabled-grey', (
tester,
) async {
await pumpActivity(tester);
// #C7C7CC is a disabled-control colour. It measured about 1.9:1 on the
// white page, which made the three words naming the columns the least
// readable text on a screen used outdoors.
for (final caption in const ['TIME', 'RIDDEN', 'COLLECTED']) {
final text = tester.widget<Text>(find.text(caption).first);
expect(text.style!.color, ColorConstants.secondaryText);
expect(text.style!.color, isNot(ColorConstants.disabledFill));
}
});
testWidgets('the four tabs agree on what colour the paper is', (
tester,
) async {
// Account was on `surface` (#FCF9F8, a warm off-white) while Home and
// Activity were on `pureSurface`. Bookings hides its background under
// cards; Account is bare rows, so the whole tab read a shade dirtier.
Color? bgOf(Widget page) {
final scaffolds = <Scaffold>[];
void visit(Element e) {
final w = e.widget;
if (w is Scaffold) scaffolds.add(w);
e.visitChildren(visit);
}
WidgetsBinding.instance.rootElement!.visitChildren(visit);
return scaffolds.isEmpty ? null : scaffolds.first.backgroundColor;
}
SharedPreferences.setMockInitialValues({'userId': 1});
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
for (final page in <Widget>[const ActivityPage(), const ProfilePage()]) {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(home: page),
),
);
await tester.pump();
expect(bgOf(page), ColorConstants.pureSurface);
}
});
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/widget/Bottom_page.dart';
/// The shell keeps every tab alive in an `IndexedStack`: all four pages are
/// built at launch and switching tabs only changes which one is painted. So a
/// page that loads in `initState` shows whatever was true before the rider did
/// anything — which is exactly how a completed stop failed to appear on
/// Activity. Pages re-read when their index comes forward, so the notifier that
/// tells them has to actually change.
void main() {
testWidgets('an IndexedStack builds every child up front', (tester) async {
final built = <int>[];
Widget page(int i) => Builder(builder: (_) {
built.add(i);
return SizedBox(key: ValueKey('page$i'));
});
await tester.pumpWidget(
MaterialApp(
home: IndexedStack(index: 0, children: [page(0), page(1), page(2)]),
),
);
expect(
built,
containsAll(<int>[0, 1, 2]),
reason: 'this is why initState cannot be a page\'s only load',
);
});
test('the shell publishes the tab on screen', () {
// Starts on Home and is a broadcast point a page can listen to.
var heard = <int>[];
void listener() => heard.add(BottomPage.currentTab.value);
BottomPage.currentTab.addListener(listener);
addTearDown(() => BottomPage.currentTab.removeListener(listener));
BottomPage.currentTab.value = 2;
BottomPage.currentTab.value = 1;
expect(heard, [2, 1]);
});
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
/// Two rules the bars now share, and both are the kind that quietly comes
/// undone the next time someone adds a screen:
///
/// • **One line.** The bar carries the page's name and nothing under it. Every
/// caption that used to sit there duplicated a figure the page itself showed
/// louder and lower down, and two sources for one number is how they drift.
///
/// • **One surface.** A frosted brand wash, not a solid slab, so a tab and a
/// pushed page read as the same app at the top of the screen.
void main() {
Future<void> pump(WidgetTester tester, Widget page) async {
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(home: page),
),
);
await tester.pumpAndSettle();
}
group('MilerAppBar', () {
testWidgets('shows the page name and nothing beneath it', (tester) async {
await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity')));
expect(find.text('Activity'), findsOneWidget);
expect(
tester.widgetList<Text>(find.byType(Text)),
hasLength(1),
reason: 'the caption under the title is gone from every bar',
);
});
testWidgets('wears the frosted brand wash, not a solid fill',
(tester) async {
await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity')));
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.backgroundColor, Colors.transparent);
expect(bar.flexibleSpace, isNotNull);
// A blur is the difference between glass and a tinted box: content
// passing under the bar softens instead of sliding under a lid.
expect(find.byType(BackdropFilter), findsOneWidget);
});
testWidgets('closes with no rule under it', (tester) async {
await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity')));
// The wash is a surface, and a surface against an untinted page is
// already an edge. A hairline under it drew that boundary a second time —
// the same doubled-up border this app has been pulling out of its cards.
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.bottom, isNull);
});
testWidgets('keeps a trailing slot for the pages that need one',
(tester) async {
await pump(
tester,
Scaffold(
appBar: MilerAppBar(
title: 'My Bookings',
trailing: const Icon(Icons.map_rounded),
),
),
);
expect(find.byIcon(Icons.map_rounded), findsOneWidget);
});
});
group('appTopBar', () {
testWidgets('uses the same wash as the tab bars', (tester) async {
await pump(tester, Scaffold(appBar: milerPageBar('Rewards', onBack: () {})));
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.backgroundColor, Colors.transparent);
expect(find.byType(BackdropFilter), findsOneWidget);
expect(bar.bottom, isNull, reason: 'no rule under the glass here either');
});
});
test('the wash is a brand tint light enough to carry near-black type', () {
// Flattened against white, which is what both the real bars and Home's
// pinned header composite it over.
final flat = Color.alphaBlend(ColorConstants.glassRed, Colors.white);
expect(
flat.computeLuminance(),
greaterThan(0.85),
reason: 'a saturated red bar would force white text onto a mid-tone red, '
'the weakest contrast pairing in the palette',
);
});
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/assignment_lookup.dart';
/// Rows shaped like GET /miler/assignments (models.BookingAssignment), which the
/// backend returns ordered by assignedat DESC.
Map<String, dynamic> row(
int assignmentId,
dynamic bookingId,
String status,
) =>
<String, dynamic>{
'bookingassignmentid': assignmentId,
'bookingid': bookingId,
'mileruserid': 42,
'assignmentstatus': status,
};
void main() {
group('AssignmentLookup.buildIndex', () {
test('maps bookingid to bookingassignmentid, not to itself', () {
final index = AssignmentLookup.buildIndex([row(9001, 5, 'Assigned')]);
// The whole point: booking 5 must resolve to assignment 9001. Returning
// the booking id here is the 404 bug this class exists to prevent.
expect(index['5'], 9001);
});
test('keeps the newest row when a booking has only terminal history', () {
// assignedat DESC — newest first.
final index = AssignmentLookup.buildIndex([
row(9003, 7, 'Rejected'),
row(9002, 7, 'Cancelled'),
]);
expect(index['7'], 9003);
});
test('prefers a live assignment over a newer terminal one', () {
// Reassignment history: the Rejected row is newer, but the rider must act
// on the still-live Assigned row.
final index = AssignmentLookup.buildIndex([
row(9010, 8, 'Rejected'),
row(9009, 8, 'Assigned'),
]);
expect(index['8'], 9009);
});
test('does not let a later terminal row displace a live one', () {
final index = AssignmentLookup.buildIndex([
row(9020, 9, 'Assigned'),
row(9019, 9, 'Rejected'),
]);
expect(index['9'], 9020);
});
test('treats Accepted as live so re-accept resolves the same assignment',
() {
final index = AssignmentLookup.buildIndex([
row(9031, 11, 'Cancelled'),
row(9030, 11, 'Accepted'),
]);
expect(index['11'], 9030);
});
test('normalises int and string booking ids to the same key', () {
final index = AssignmentLookup.buildIndex([
row(9040, 12, 'Assigned'),
row(9041, '13', 'Assigned'),
]);
expect(index['12'], 9040);
expect(index['13'], 9041);
});
test('coerces a string assignment id to int', () {
final index = AssignmentLookup.buildIndex([
<String, dynamic>{
'bookingassignmentid': '9050',
'bookingid': 14,
'assignmentstatus': 'Assigned',
}
]);
expect(index['14'], 9050);
});
test('skips rows missing either id rather than mapping them to garbage', () {
final index = AssignmentLookup.buildIndex([
<String, dynamic>{'bookingid': 15, 'assignmentstatus': 'Assigned'},
<String, dynamic>{
'bookingassignmentid': 9060,
'assignmentstatus': 'Assigned'
},
<String, dynamic>{
'bookingassignmentid': null,
'bookingid': null,
'assignmentstatus': 'Assigned'
},
]);
expect(index, isEmpty);
});
test('ignores non-map entries', () {
final index = AssignmentLookup.buildIndex([
'nonsense',
42,
null,
row(9070, 16, 'Assigned'),
]);
expect(index, {'16': 9070});
});
test('handles an empty list', () {
expect(AssignmentLookup.buildIndex([]), isEmpty);
});
});
}

353
test/auth_design_test.dart Normal file
View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/app_theme.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
/// The onboarding flow's shared frame. These pin the two things that were
/// actually broken in the five hand-rolled versions it replaced: code boxes
/// sized in fixed `.w` units inside a `spaceBetween` row, and a CTA inheriting
/// the app theme's full-width-form metrics.
void main() {
Future<void> pump(
WidgetTester tester,
Widget child, {
Size screen = const Size(390, 844),
}) async {
tester.view.physicalSize = Size(screen.width * 3, screen.height * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
theme: AppTheme.light,
home: child,
),
),
);
await tester.pumpAndSettle();
}
Widget codeRow(int count, {bool error = false}) {
final controllers =
List.generate(count, (_) => TextEditingController());
final nodes = List.generate(count, (_) => FocusNode());
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: AuthCodeRow(
controllers: controllers,
nodes: nodes,
hasError: error,
onChanged: (_, _) {},
),
),
);
}
group('AuthCodeRow', () {
// The old OTP row hard-coded 46.w boxes and the MPIN rows hard-coded 70.w,
// inside `MainAxisAlignment.spaceBetween`. That only fits because the design
// width happens to divide that way; it has no headroom and cannot adapt.
for (final count in [4, 6]) {
for (final width in [320.0, 360.0, 390.0, 430.0]) {
testWidgets('$count boxes fit a ${width.toInt()}pt screen',
(tester) async {
await pump(tester, codeRow(count), screen: Size(width, 780));
expect(tester.takeException(), isNull);
expect(find.byType(TextField), findsNCount(count));
// Every box inside the 24pt gutters, none overlapping.
final boxes = tester
.widgetList<TextField>(find.byType(TextField))
.toList();
expect(boxes.length, count);
final rects = find.byType(TextField).evaluate().map((e) {
final ro = e.renderObject! as RenderBox;
final pos = ro.localToGlobal(Offset.zero);
return Rect.fromLTWH(pos.dx, pos.dy, ro.size.width, ro.size.height);
}).toList();
for (final r in rects) {
expect(r.left, greaterThanOrEqualTo(-0.01));
expect(r.right, lessThanOrEqualTo(width + 0.01));
expect(r.width, greaterThan(0));
}
});
}
}
testWidgets('an error turns the boxes red', (tester) async {
await pump(tester, codeRow(6, error: true));
expect(tester.takeException(), isNull);
});
});
group('AuthPrimaryButton', () {
testWidgets('the label is not squeezed by the theme form metrics',
(tester) async {
await pump(
tester,
const Scaffold(
body: Padding(
padding: EdgeInsets.all(24),
child: AuthPrimaryButton(label: 'Verify & Continue', onPressed: _noop),
),
),
);
expect(tester.takeException(), isNull);
final button = tester.getRect(find.byType(ElevatedButton));
final icon = tester.getRect(find.byType(Icon));
final text = tester.getRect(find.text('Verify & Continue'));
// The app theme's `vertical: 18` padding would leave a 20pt content band
// in a 56pt button and crush both of these; see AuthPrimaryButton's note.
expect(icon.height, greaterThan(14));
expect(text.height, greaterThan(12));
for (final part in [icon, text]) {
expect(part.top, greaterThanOrEqualTo(button.top - 0.01));
expect(part.bottom, lessThanOrEqualTo(button.bottom + 0.01));
}
});
testWidgets('a null callback disables it', (tester) async {
await pump(
tester,
const Scaffold(
body: Padding(
padding: EdgeInsets.all(24),
child: AuthPrimaryButton(label: 'Continue', onPressed: null),
),
),
);
final b = tester.widget<ElevatedButton>(find.byType(ElevatedButton));
expect(b.onPressed, isNull);
});
});
group('AuthScaffold', () {
testWidgets('pins the footer below the scrolling body', (tester) async {
await pump(
tester,
AuthScaffold(
onBack: _noop,
step: 2,
title: 'Enter the 6-digit code',
subtitle: 'Sent by SMS.',
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
children: [Container(height: 200, color: Colors.white)],
),
);
expect(tester.takeException(), isNull);
expect(find.text('STEP 2 OF 3'), findsOneWidget);
expect(find.text('Enter the 6-digit code'), findsOneWidget);
final body = tester.getRect(find.byType(SingleChildScrollView));
final footer = tester.getRect(find.byType(ElevatedButton));
expect(footer.top, greaterThanOrEqualTo(body.bottom - 0.01),
reason: 'the CTA sits below the scroll area, not inside it');
});
testWidgets('the back target clears the 44dp floor', (tester) async {
await pump(
tester,
AuthScaffold(
onBack: _noop,
title: 'T',
footer: const AuthPrimaryButton(label: 'Go', onPressed: _noop),
children: const [],
),
);
final back = tester.getRect(find.byIcon(Icons.arrow_back_rounded));
// The glyph is small; the tappable box around it must not be.
final target = tester.getRect(find.ancestor(
of: find.byIcon(Icons.arrow_back_rounded),
matching: find.byType(InkResponse),
));
expect(back.width, lessThan(target.width));
expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
});
testWidgets('no step rail when the screen is outside the flow',
(tester) async {
await pump(
tester,
AuthScaffold(
title: 'Enter your MPIN',
footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop),
children: const [],
),
);
expect(find.textContaining('STEP'), findsNothing);
});
// ── The banner is opt-in ──
//
// Only Sign In passes one. The other three screens in the flow must keep
// the plain top-anchored frame, because a brand block there would spend
// keyboard room on a screen whose whole job is to accept six digits.
testWidgets('no banner unless a screen asks for one', (tester) async {
await pump(
tester,
AuthScaffold(
title: 'Enter the 6-digit code',
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
children: const [],
),
);
expect(find.byType(AuthBrandBanner), findsNothing);
});
});
/// ── The brand block on Sign In ──
///
/// The two things that can actually break here: the block eating the field on
/// a short screen with the keyboard up, and the wordmark being squashed by a
/// layout change. Both are asserted rather than eyeballed.
group('AuthBrandBanner', () {
Widget bannered() => AuthScaffold(
step: 1,
banner: const AuthBrandBanner(),
title: 'Enter your number',
subtitle: 'We use it to sign you in.',
footer: const AuthPrimaryButton(label: 'Continue', onPressed: _noop),
children: [Container(height: 56, color: Colors.white)],
);
for (final width in [320.0, 360.0, 390.0, 430.0]) {
testWidgets('lays out on a ${width.toInt()}pt screen', (tester) async {
await pump(tester, bannered(), screen: Size(width, 720));
expect(tester.takeException(), isNull);
expect(find.byType(AuthBrandBanner), findsOneWidget);
expect(find.text('MILER · RIDER APP'), findsOneWidget);
// The block must not run away with the screen. Half the height is the
// point at which the sheet stops being the subject of the page.
final banner = tester.getRect(find.byType(AuthBrandBanner));
expect(banner.height, lessThan(720 * 0.5),
reason: 'the brand block is a header, not the page');
});
}
testWidgets('the wordmark keeps its aspect ratio', (tester) async {
await pump(tester, bannered());
final mark = tester.getRect(find.image(
const AssetImage('assets/images/doormile-logo.png'),
));
// Source is 748×100. A squashed mark is the classic symptom of a banner
// being re-laid-out without the ratio being held explicitly.
expect(mark.width / mark.height, closeTo(748 / 100, 0.05));
});
testWidgets('it gets out of the way when the keyboard opens',
(tester) async {
await pump(tester, bannered());
final atRest = tester.getRect(find.byType(AuthBrandBanner)).height;
// A keyboard is a bottom view inset; that is the only signal the widget
// reads, so it is the only one the test needs to fake.
//
// This is also the regression guard for a real bug: `Scaffold` with
// `resizeToAvoidBottomInset` strips the bottom inset from everything in
// its `body`, so the banner saw zero however high the keyboard was. It
// only works because AuthScaffold re-provides its own MediaQuery.
tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
final typing = tester.getRect(find.byType(AuthBrandBanner)).height;
expect(typing, lessThan(atRest),
reason: 'the block halves itself so the field stays visible');
// And the mark is still there — it shrinks, it does not disappear.
expect(find.byType(AuthBrandBanner), findsOneWidget);
});
// The screens that focus a box on the first frame must not render the full
// block and then animate it away — that is the deleted splash, rebuilt.
testWidgets('compact starts small and stays small', (tester) async {
await pump(
tester,
AuthScaffold(
banner: const AuthBrandBanner(compact: true),
title: 'Enter your MPIN',
footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop),
children: const [],
),
);
final first = tester.getRect(find.byType(AuthBrandBanner)).height;
tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(AuthBrandBanner)).height, first,
reason: 'a compact banner has nowhere to shrink to');
});
// ── The banner used to eat the back button ──
//
// The plain layout renders `onBack` in its own row above the content. The
// bannered one has no such row, so a screen passing `onBack` lost its only
// way out — which is three of the four screens in this flow.
testWidgets('back survives the banner', (tester) async {
await pump(
tester,
AuthScaffold(
onBack: _noop,
banner: const AuthBrandBanner(compact: true),
title: 'Enter the 6-digit code',
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
children: const [],
),
);
expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget);
final target = tester.getRect(find.ancestor(
of: find.byIcon(Icons.arrow_back_rounded),
matching: find.byType(InkResponse),
));
expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
// On the banner, not below it.
final banner = tester.getRect(find.byType(AuthBrandBanner));
expect(target.center.dy, lessThan(banner.bottom));
});
});
group('AuthInlineError', () {
testWidgets('takes no space when there is nothing to say', (tester) async {
await pump(
tester,
const Scaffold(body: Center(child: AuthInlineError())),
);
expect(tester.getSize(find.byType(AuthInlineError)).height, 0);
});
testWidgets('shows the message when there is', (tester) async {
await pump(
tester,
const Scaffold(
body: Center(child: AuthInlineError(message: 'That code is wrong.')),
),
);
expect(find.text('That code is wrong.'), findsOneWidget);
expect(tester.getSize(find.byType(AuthInlineError)).height,
greaterThan(0));
});
});
}
void _noop() {}
/// `findsNWidgets` reads badly inline at these call sites.
Matcher findsNCount(int n) => findsNWidgets(n);

View File

@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
/// Bookings shows one flat list — no trip tabs, deliberately — so the only
/// thing telling the rider which of the day's runs he is on is the label above
/// it. The number has to mean what the hub means by it, and that is the part
/// worth pinning down: the numbering is against the WHOLE day, not against the
/// subset of it the rider happened to accept.
///
/// This exercises the same `groupIntoTrips` + positional numbering the screen
/// uses, which is the piece that can silently go wrong.
void main() {
Map<String, dynamic> stop(String id, String slotFrom, String slotTo) =>
<String, dynamic>{
'orderid': id,
'pickupcustomer': 'Shop $id',
'slotstarttime': slotFrom,
'slotendtime': slotTo,
};
/// Order id → 1-based trip number, the way `_numberTrips` builds it.
Map<String, int> numberTrips(List<Map<String, dynamic>> allToday) {
final byId = <String, Map<String, dynamic>>{};
for (final s in allToday) {
final id = (s['orderid'] ?? '').toString();
if (id.isEmpty) continue;
byId.putIfAbsent(id, () => s);
}
final trips = Trip.groupIntoTrips(byId.values.toList());
return <String, int>{
for (var i = 0; i < trips.length; i++)
for (final id in trips[i].orderIds) id: i + 1,
};
}
final morning = [
stop('A', '2026-08-06T09:00:00', '2026-08-06T12:00:00'),
stop('B', '2026-08-06T09:00:00', '2026-08-06T12:00:00'),
];
final afternoon = [
stop('C', '2026-08-06T14:00:00', '2026-08-06T17:00:00'),
];
test('trips are numbered by their slot, earliest first', () {
final numbers = numberTrips([...afternoon, ...morning]);
expect(numbers['A'], 1);
expect(numbers['B'], 1);
expect(numbers['C'], 2);
});
test('a rider who took only the afternoon is still on Trip 2', () {
// ── The reason this is computed from the day and not from the queue ──
//
// Numbering only the accepted stops would call this run "Trip 1", while the
// hub, Home and the rider's own supervisor all call it Trip 2. The label is
// a shared name or it is worse than nothing.
final dayNumbers = numberTrips([...morning, ...afternoon]);
expect(dayNumbers['C'], 2);
final queueOnly = numberTrips(afternoon);
expect(
queueOnly['C'],
1,
reason: 'this is exactly the wrong answer the screen must not give',
);
});
test('a stop appearing twice in one fetch does not split its trip', () {
// The same stop arrives from up to three sources per fetch — v1, v3 and the
// local accepted store — and each copy carries the same slot.
final numbers = numberTrips([...morning, ...morning, ...afternoon]);
expect(numbers['A'], 1);
expect(numbers['C'], 2);
});
test('a day with one slot is Trip 1, not "your route"', () {
expect(numberTrips(morning)['A'], 1);
});
}

View File

@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// The floating nav bar used to slide off the bottom of the screen as the rider
/// moved down a list and come back when he reversed. That buys 66pt of list and
/// costs him the ability to change tab at all until he scrolls the other way —
/// on a long Activity list the only route out is a gesture he has to know
/// about. A control that vanishes is not a smaller control; it is a missing one.
///
/// It compacts instead: labels collapse, icons draw in, the pill narrows and
/// shortens, and every target stays on screen. This is Instagram's iOS tab bar.
///
/// `BottomPage` builds four real tab pages (Get controllers, SharedPreferences,
/// Geolocator, live fetches) and cannot be pumped in a unit test, so — as in
/// `card_stripe_layout_test.dart` — these pin the *idiom* rather than the
/// widget. Two halves, only one of them obvious:
///
/// • A `NotificationListener<ScrollNotification>` accumulates `scrollDelta`
/// from `ScrollUpdateNotification`. Deliberately not `UserScrollNotification`:
/// that fires when a drag *begins*, when `metrics.pixels` has barely moved,
/// so a distance threshold measured there compares an offset against itself
/// and never fires at all.
///
/// • The label collapses via `Align(heightFactor:)` inside a `ClipRect`, not a
/// fade. Fading alone leaves the label's height behind, so the bar cannot
/// actually get shorter — it shrinks around a blank line.
void main() {
const barKey = ValueKey('nav');
const labelKey = ValueKey('label');
const double resting = 66;
const double compactHeight = 46;
/// The shell shape, with the same wiring `BottomPage` uses.
Widget shell({
required ValueNotifier<double> compact,
required ValueNotifier<double> accum,
bool scrollable = true,
}) {
return MaterialApp(
home: ValueListenableBuilder<double>(
valueListenable: compact,
builder: (context, k, _) => Scaffold(
body: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.axis != Axis.vertical) return false;
if (n.metrics.maxScrollExtent <= 0) {
accum.value = 0;
compact.value = 0;
return false;
}
if (n is! ScrollUpdateNotification) return false;
final delta = n.scrollDelta ?? 0;
if (delta == 0) return false;
accum.value =
(accum.value * delta > 0) ? accum.value + delta : delta;
if (delta > 0 && compact.value == 0 && accum.value > 24) {
accum.value = 0;
compact.value = 1;
} else if (delta < 0 && compact.value == 1 && accum.value < -6) {
accum.value = 0;
compact.value = 0;
}
return false;
},
child: ListView(
children: [SizedBox(height: scrollable ? 3000 : 10)],
),
),
bottomNavigationBar: SizedBox(
key: barKey,
height: resting + (compactHeight - resting) * k,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.home_rounded, size: 21),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: (1 - k).clamp(0.0, 1.0),
child: Opacity(
opacity: (1 - k * 1.8).clamp(0.0, 1.0),
child: const Text('Home', key: labelKey),
),
),
),
],
),
),
),
),
);
}
double barHeight(WidgetTester tester) =>
tester.getSize(find.byKey(barKey)).height;
testWidgets('scrolling down shrinks the bar; scrolling back restores it',
(tester) async {
final compact = ValueNotifier(0.0);
final accum = ValueNotifier(0.0);
addTearDown(compact.dispose);
addTearDown(accum.dispose);
await tester.pumpWidget(shell(compact: compact, accum: accum));
expect(barHeight(tester), resting);
// Finger bottom → up: the list scrolls forward, the bar draws in.
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(barHeight(tester), compactHeight);
// Finger top → down: he is heading back, so it returns to full size.
await tester.drag(find.byType(ListView), const Offset(0, 300));
await tester.pumpAndSettle();
expect(barHeight(tester), resting);
});
testWidgets('the bar never leaves — the rider can always change tab',
(tester) async {
// This is the whole reason the behaviour changed. Whatever the rider does
// with his thumb, the tab targets stay on screen and stay tappable.
final compact = ValueNotifier(0.0);
final accum = ValueNotifier(0.0);
addTearDown(compact.dispose);
addTearDown(accum.dispose);
await tester.pumpWidget(shell(compact: compact, accum: accum));
for (final drag in const [-300.0, -600.0, -1200.0]) {
await tester.drag(find.byType(ListView), Offset(0, drag));
await tester.pumpAndSettle();
expect(barHeight(tester), greaterThanOrEqualTo(compactHeight),
reason: 'the bar must never collapse past its compact height');
expect(find.byType(Icon), findsOneWidget,
reason: 'and its icons must stay on screen');
}
});
testWidgets('the label collapses rather than merely fading', (tester) async {
// A fade alone leaves the label's height behind, so the bar would shrink
// around a blank line and never reach its compact height.
final compact = ValueNotifier(0.0);
final accum = ValueNotifier(0.0);
addTearDown(compact.dispose);
addTearDown(accum.dispose);
await tester.pumpWidget(shell(compact: compact, accum: accum));
final labelled = tester.getSize(find.byKey(labelKey)).height;
expect(labelled, greaterThan(0));
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
// The Align is what gives the room back; the Text keeps its own size and is
// clipped, so measuring the Text would report the same height either way.
final align = tester.getSize(
find.ancestor(of: find.byKey(labelKey), matching: find.byType(Align)).first,
);
expect(align.height, lessThan(1),
reason: 'the label must hand its height back, not just go invisible');
});
testWidgets('a page too short to scroll never compacts the bar',
(tester) async {
final compact = ValueNotifier(0.0);
final accum = ValueNotifier(0.0);
addTearDown(compact.dispose);
addTearDown(accum.dispose);
await tester
.pumpWidget(shell(compact: compact, accum: accum, scrollable: false));
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(barHeight(tester), resting);
});
testWidgets('a horizontal scroll leaves the bar alone', (tester) async {
// Tab strips and carousels scroll sideways; the nav bar is not theirs.
final compact = ValueNotifier(0.0);
addTearDown(compact.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.axis != Axis.vertical) return false;
compact.value = 1;
return false;
},
child: ListView(
scrollDirection: Axis.horizontal,
children: [Container(width: 3000)],
),
),
),
),
);
await tester.drag(find.byType(ListView), const Offset(-300, 0));
await tester.pumpAndSettle();
expect(compact.value, 0);
});
}

View 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();
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/app_theme.dart';
/// The stop card's CTA is an `ElevatedButton.icon` inside a fixed-height
/// `SizedBox`, and the app theme styles `ElevatedButton` for full-width FORM
/// buttons: `padding: vertical 18` and a 56dp `minimumSize`.
///
/// Those two defaults are a trap for any button in a list row. `styleFrom`
/// replaces only the keys it is handed, so a card that overrides colour, shape
/// and elevation still inherits the form metrics. The padding then eats the
/// height from the inside: at 48dp the content band is 48 − 36 = **12px**, and
/// the label and icon are squeezed into it — glyphs cut through the middle,
/// icon painted at 12px instead of its declared 19px.
///
/// Two things make it worth a test. It is **silent** — no overflow exception,
/// nothing in the console, the button just renders half a word — and it is
/// invisible at 56dp, where the band is exactly 20px and one line happens to
/// fit. So the bug is dormant in any card using the theme's metrics and wakes
/// up the moment someone changes the height.
///
/// `PickupCard` is private to the `pickups.dart` library and cannot be
/// imported, so — as in `card_stripe_layout_test.dart` — these tests pin the
/// idiom rather than the widget.
void main() {
const iconSize = 19.0;
const labelFontSize = 14.5;
const label = 'Start Combined Stop';
/// The card's CTA, at the height the card gives it.
Widget cta({required bool withOverrides, double height = 48}) {
return MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: Center(
child: SizedBox(
width: 268, // CTA share of a 390pt-wide card, beside the ⋯ button
child: SizedBox(
height: height,
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.arrow_forward_rounded, size: iconSize),
label: const Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w800, fontSize: labelFontSize),
),
style: withOverrides
? ElevatedButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
: ElevatedButton.styleFrom(elevation: 0),
),
),
),
),
),
);
}
testWidgets('the themed form metrics squeeze a 48dp CTA', (tester) async {
await tester.pumpWidget(cta(withOverrides: false));
// Nothing is thrown — this is the failure mode that ships.
expect(tester.takeException(), isNull);
// 48 − (18 + 18) of inherited vertical padding leaves a 12px content band,
// and everything in the button is crushed into it. The icon is the clean
// witness: it asked for 19px and cannot have been drawn correctly at 12.
final content = tester.getRect(find.byIcon(Icons.arrow_forward_rounded));
expect(content.height, lessThan(iconSize),
reason: 'this is the squeeze that cut the button label in half');
expect(tester.getRect(find.text(label)).height, lessThan(labelFontSize),
reason: 'the label box is shorter than one line of its own text');
});
testWidgets('overriding padding and minimumSize lets the label fit',
(tester) async {
await tester.pumpWidget(cta(withOverrides: true));
expect(tester.takeException(), isNull);
final button = tester.getRect(find.byType(ElevatedButton));
final icon = tester.getRect(find.byIcon(Icons.arrow_forward_rounded));
final text = tester.getRect(find.text(label));
// The SizedBox is the single source of truth for the height.
expect(button.height, 48);
// Nothing is squeezed: the icon gets the size it asked for, and the label
// gets at least a full line box.
expect(icon.height, iconSize);
expect(text.height, greaterThanOrEqualTo(labelFontSize));
// And both sit fully inside the button rather than being clipped by it.
for (final part in [icon, text]) {
expect(part.top, greaterThanOrEqualTo(button.top));
expect(part.bottom, lessThanOrEqualTo(button.bottom));
expect(part.left, greaterThanOrEqualTo(button.left));
expect(part.right, lessThanOrEqualTo(button.right));
}
});
// ── The same trap, on OutlinedButton ──
//
// Not a hypothetical: an audit found `sheet.dart` giving a 48dp box to an
// `OutlinedButton.icon` whose style overrode only the side and the shape. The
// theme's `outlinedButtonTheme` carries `vertical: 16`, leaving a 16px content
// band, and the row's 18sp icon was measured painting at **16px**. Silently —
// this produces no overflow error, which is why it survived.
group('OutlinedButton inherits the same form metrics', () {
Widget outlined({required bool withOverrides, double height = 48}) {
return MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: Center(
child: SizedBox(
width: 220,
height: height,
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.close_rounded, size: 18),
label: const Text('Skip stop',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)),
style: withOverrides
? OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 14),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
: null,
),
),
),
),
);
}
testWidgets('squeezes the icon without the overrides', (tester) async {
await tester.pumpWidget(outlined(withOverrides: false));
expect(tester.takeException(), isNull, reason: 'it fails silently');
expect(tester.getRect(find.byType(Icon)).height, lessThan(18),
reason: 'this is the shape that shrank the icon to 16px');
});
testWidgets('and does not with them', (tester) async {
await tester.pumpWidget(outlined(withOverrides: true));
expect(tester.takeException(), isNull);
final button = tester.getRect(find.byType(OutlinedButton));
final icon = tester.getRect(find.byType(Icon));
expect(button.height, 48);
expect(icon.height, 18, reason: 'the icon gets the size it asked for');
expect(icon.top, greaterThanOrEqualTo(button.top - 0.01));
expect(icon.bottom, lessThanOrEqualTo(button.bottom + 0.01));
});
});
}

990
test/card_density_test.dart Normal file
View File

@@ -0,0 +1,990 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/route_brief.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/Dashboard/home/trip_brief_strip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// Density is the whole point of these two redesigns, so it is asserted rather
/// than claimed. A phone screen is roughly 844pt tall; how much of it the
/// permanent furniture eats decides how many stops the rider can see.
void main() {
Future<void> pump(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(body: SingleChildScrollView(child: child)),
),
),
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stop(String id, {double cash = 0}) => {
'orderid': id,
'type': 'pickup',
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore',
'pickupqty': '3',
'Quantity': '3',
if (cash > 0) 'collectionamt': '$cash',
'expected_pickup_time': DateTime.now().toIso8601String(),
};
// ── The trip brief is collapsed by default, and that is the whole point ──
//
// The four surfaces this replaced — a shift strip, a "CURRENT SHIFT" card, a
// 66dp ring and three stat cards — measured ~351dp at this design size, on a
// scrollable region of roughly 650. Over half the screen was spent before the
// rider saw a stop. These tests hold the new budget, because the failure mode
// is exactly the one that produced the old header: a line at a time, each
// individually defensible.
group('Trip brief strip', () {
Future<double> briefHeight(WidgetTester tester, {double cash = 0}) async {
await pump(
tester,
TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: cash)])),
);
return tester.getSize(find.byType(TripBriefStrip)).height;
}
testWidgets('stays compact when collapsed', (tester) async {
final h = await briefHeight(tester);
expect(
h,
lessThan(90),
reason:
'measured $h — the brief is the permanent furniture above the '
'route, so every point of it is charged at every scroll position',
);
});
testWidgets('cash does not make it taller', (tester) async {
// The COD figure rides as a chip on the same row, not a fourth card and
// not a second line.
final without = await briefHeight(tester);
final with_ = await briefHeight(tester, cash: 1250);
expect(
with_,
without,
reason: 'adding a COD figure must not grow the collapsed strip',
);
});
testWidgets('holds the journey figures back until it is opened', (
tester,
) async {
await pump(
tester,
TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 940)])),
);
// The journey tiles are small-caps. ('Trip slot' was in this list until
// the row was removed — see the shift/slot test below.)
const briefLabels = [
'DURATION',
'DISTANCE',
'PARCELS',
'PAYMENT',
];
// Collapsed: progress and time, nothing else.
for (final label in briefLabels) {
expect(
find.text(label),
findsNothing,
reason: '"$label" is briefing material — it must not be on the face',
);
}
expect(
find.text('CURRENT SHIFT'),
findsNothing,
reason: 'the mislabelled shift caption is gone with the card',
);
final collapsed = tester.getSize(find.byType(TripBriefStrip)).height;
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
for (final label in briefLabels) {
expect(find.text(label), findsOneWidget, reason: '"$label" on open');
}
expect(
tester.getSize(find.byType(TripBriefStrip)).height,
greaterThan(collapsed),
reason: 'opening the brief must actually reveal something',
);
expect(tester.takeException(), isNull);
});
testWidgets('lays out inside a scroll view, open or shut', (tester) async {
// The row this replaced used `CrossAxisAlignment.stretch`, which needs an
// `IntrinsicHeight` above it or the cards are asked to be infinitely tall
// in an unbounded scroll extent — see `card_stripe_layout_test.dart`. It
// cost a build once. The replacement has no stretch row at all, and this
// holds that it stays that way.
await pump(
tester,
TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 1310)])),
);
expect(tester.takeException(), isNull);
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
});
group('Trip tabs', () {
testWidgets('fit in one compact row', (tester) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: 0,
onSelect: (_) {},
),
);
final h = tester.getSize(find.byType(TripTabs)).height;
// ── This bound was raised deliberately, and it cost something ──
//
// The strip went from one line (~41pt) to a segmented control carrying a
// label, a status line and a running-indicator (~70pt). That is ~30pt of
// *permanent* height, and it is worse than it sounds because the bar is
// now a pinned sliver: it is on screen at every scroll position, not just
// at the top.
//
// What it buys is the separation of "the trip I am looking at" from "the
// trip that is running", which the one-line version could not express at
// all, plus per-trip progress in words rather than an ambiguous bar. That
// is a real trade, taken on purpose — but the guard stays, because the
// failure mode here is a strip that keeps growing a line at a time until
// it crowds out the route it is supposed to be navigating.
expect(
h,
lessThan(80),
reason:
'the tab strip is pinned furniture — it is on screen at '
'every scroll position, so its height is charged continuously',
);
expect(tester.takeException(), isNull);
});
testWidgets('still show three slots before the hub assigns them', (
tester,
) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
],
selectedIndex: 0,
onSelect: (_) {},
),
);
expect(find.text('Trip 1'), findsOneWidget);
expect(find.text('Trip 2'), findsOneWidget);
expect(find.text('Trip 3'), findsOneWidget);
});
});
// ── A stop row carries no controls at all ──
//
// Every row used to have its own Accept and Reject, and for a while an
// accepted row kept a live Reject beside its own green confirmation — so a
// stop could be accepted and then rejected a second later, with the two wire
// calls racing. Both buttons are gone from the row entirely now: ticking
// chooses, and the floating bar is the only thing that commits.
group('decision row', () {
Future<void> pumpStop(
WidgetTester tester, {
Set<String> accepted = const {},
Set<String> rejected = const {},
}) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
acceptedIds: accepted,
rejectedIds: rejected,
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
}
testWidgets('an undecided stop offers a tick, not buttons', (tester) async {
await pumpStop(tester);
for (final label in ['Reject', 'Accept', 'Accept instead']) {
expect(
find.widgetWithText(MilerButton, label),
findsNothing,
reason: '"$label" is not a per-stop control any more',
);
}
// The tick is what is left, and it is how a stop is chosen.
expect(find.bySemanticsLabel('Select stop 1'), findsOneWidget);
});
testWidgets('an accepted stop leaves the screen entirely', (tester) async {
await pumpStop(tester, accepted: {'A'});
// Home lists what still needs deciding here. Once a stop is taken it is
// work in hand, and work in hand lives on Bookings — the row used to stay
// behind saying "Accepted", which is a row the rider can do nothing with.
expect(find.text('Sri Balaji Stores'), findsNothing);
expect(find.bySemanticsLabel('Select stop 1'), findsNothing);
// The trip says what happened to it instead of leaving a gap.
expect(
find.text('Every stop on this trip is taken — work them from Bookings'),
findsOneWidget,
);
});
testWidgets('a skipped stop leaves the screen, like a finished one', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [
{...stop('A'), 'orderstatus': 'skipped'},
]),
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
// A skip is a record of something that happened at a door, not an offer
// waiting on a decision — so it is Activity's row now, with the Resume
// control on it. Home is only ever "what am I taking?".
expect(find.text('Sri Balaji Stores'), findsNothing);
expect(find.widgetWithText(MilerButton, 'Resume'), findsNothing);
expect(
find.text('Every stop on this trip is taken — work them from Bookings'),
findsOneWidget,
);
});
testWidgets('a rejected stop can be put back to undecided', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
rejectedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
onUnrejectStop: (_) async {},
),
);
expect(find.widgetWithText(MilerButton, 'Undo reject'), findsOneWidget);
// Undoing returns it to the queue; it does not accept it outright.
expect(find.widgetWithText(MilerButton, 'Accept'), findsNothing);
});
testWidgets('a rejected stop is stated by its chip, not a button', (
tester,
) async {
await pumpStop(tester, rejected: {'A'});
expect(find.text('Rejected'), findsOneWidget);
expect(
find.widgetWithText(MilerButton, 'Rejected'),
findsNothing,
reason: 'a button that cannot be pressed is not a status',
);
});
});
// ── One accept in the whole screen ──
//
// The footer used to carry "Accept all N stops", a second bulk action over a
// different set of stops from the floating bar's — and the difference between
// the two sets was exactly the stops the rider had just decided against.
group('bulk action', () {
List<Map<String, dynamic>> threeStops() => [
stop('A'),
stop('B'),
stop('C'),
];
testWidgets('the footer has no accept button of its own', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: threeStops()),
onAcceptSelected: (_) async {},
onToggleSelect: (_) {},
),
);
expect(find.textContaining('Accept all'), findsNothing);
expect(find.textContaining('Accept remaining'), findsNothing);
expect(find.textContaining('Continue trip'), findsNothing);
// It points at the one accept there is instead.
expect(
find.text('Tick the stops you want, then accept them below'),
findsOneWidget,
);
});
testWidgets('and says where the action is once stops are ticked', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: threeStops()),
selectedIds: const {'A', 'B'},
onAcceptSelected: (_) async {},
onToggleSelect: (_) {},
),
);
expect(
find.text('Accept the stops you picked from the bar below'),
findsOneWidget,
);
});
});
group('Trip brief contents', () {
testWidgets('the shift is labelled, and the trip slot is not repeated', (
tester,
) async {
// Two bugs, closed one after the other.
//
// First: the old card captioned `trip.slotLabel` — the TRIP's window — as
// "CURRENT SHIFT", while the rider's actual shift sat in a separate strip
// directly above it. Two ranges, stacked, one wearing the other's name.
// The fix was a labelled row each.
//
// Then the slot row turned out to be printing a range the rider could
// already see: the collapsed face falls back to `slotLabel`, and the trip
// TAB above the strip is labelled with it too. So the slot keeps its
// places and the brief keeps only the window that is genuinely its own.
final trip = Trip.fromStops(id: 't1', stops: [stop('A')]);
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
);
await pump(tester, TripCard(trip: trip, brief: brief));
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
expect(find.text('Shift'), findsOneWidget);
expect(
find.text('Trip slot'),
findsNothing,
reason: 'the slot is on the face and on the tab; a third copy is noise',
);
expect(
find.text(brief.shiftWindowLabel),
findsOneWidget,
reason: "the shift row states the rider's window, not the trip's",
);
});
testWidgets('the brief holds no actions, only figures', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('MAP'),
),
findsNothing,
reason:
'the map is an action; it belongs on the route, not in a list of '
'read-only figures',
);
});
});
// ── The map opens from where the route begins ──
//
// It used to be buried at the foot of the trip brief: an action among nine
// read-only figures, two taps deep behind a strip the rider has no other
// reason to open. It now sits on the START · HUB row, which is both where the
// route starts and where his eye already is when he starts it.
group('MAP on the hub row', () {
testWidgets('is reachable without opening anything', (tester) async {
var opened = false;
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () => opened = true,
),
);
expect(find.text('MAP'), findsOneWidget);
expect(
tester.getRect(find.text('MAP')).top,
lessThan(tester.getRect(find.text('Sri Balaji Stores')).top),
reason: 'it sits on the hub row, above the first stop',
);
await tester.tap(find.text('MAP'));
await tester.pumpAndSettle();
expect(opened, isTrue);
});
// ── It has to fit in the row it sits on ──
//
// Aligning the hub node with its label once boxed the whole row to the
// node's 22dp height, which cropped this control — a tap target with its
// own padding and a chevron — through the middle. The band is sized for the
// tallest thing on the row now, and this is what says so.
testWidgets('is drawn whole, not cropped by the row it sits in', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
final label = tester.getRect(find.text('MAP'));
final chevron = tester.getRect(find.byIcon(Icons.chevron_right_rounded));
final icon = tester.getRect(find.byIcon(Icons.map_rounded));
// Every part of the control has real height and shares a centre line
// with the rest of it.
for (final part in [label, chevron, icon]) {
expect(part.height, greaterThan(0));
}
expect(chevron.center.dy, closeTo(label.center.dy, 1.5));
expect(icon.center.dy, closeTo(label.center.dy, 1.5));
});
// The alignment the band was introduced for in the first place.
testWidgets('and the hub node shares its centre line with START · HUB', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
final node = tester.getRect(find.byIcon(Icons.warehouse_rounded).first);
final label = tester.getRect(find.text('START · HUB'));
expect(node.center.dy, closeTo(label.center.dy, 1.5));
});
testWidgets('and stays hidden when there is nothing to open', (
tester,
) async {
await pump(
tester,
TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A')])),
);
expect(
find.text('MAP'),
findsNothing,
reason: 'the action is hidden until a callback is supplied',
);
});
testWidgets('only on START · HUB, never on RETURN · HUB', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
expect(
find.text('MAP'),
findsOneWidget,
reason: 'one map action per trip, on the row the route starts from',
);
expect(
tester.getRect(find.text('MAP')).top,
lessThan(tester.getRect(find.text('RETURN · HUB')).top),
);
});
});
group('Trip tab states', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
);
}
Color colourOf(WidgetTester tester, String label) =>
tester.widget<Text>(find.text(label)).style!.color!;
testWidgets('the selected tab takes the brand accent', (tester) async {
// The bug: the pill moved but the label stayed slate, so standing on
// Trip 2 looked no different from Trip 2 sitting idle.
await pumpTabs(tester, selected: 1);
expect(
colourOf(tester, 'Trip 2'),
ColorConstants.primary,
reason: 'the tab the rider is on must read as picked',
);
expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary));
await pumpTabs(tester, selected: 0);
expect(colourOf(tester, 'Trip 1'), ColorConstants.primary);
expect(
colourOf(tester, 'Trip 3'),
isNot(ColorConstants.primary),
reason: 'an unassigned slot stays quiet',
);
});
testWidgets('the strip stays compact after the state work', (tester) async {
await pumpTabs(tester, selected: 0);
final h = tester.getSize(find.byType(TripTabs)).height;
expect(
h,
lessThan(66),
reason:
'measured $h — the segmented strip is pinned furniture, so '
'every point of it is charged at every scroll position',
);
});
});
group('Trip tab selection marker', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
);
}
/// Same tree, but leaves the pill mid-flight so it can be sampled.
Future<void> pumpTabsNoSettle(
WidgetTester tester, {
required int selected,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
),
),
),
),
);
await tester.pump();
}
/// The travelling selection marker.
///
/// Found by key, not by colour. It moved out of the segments and into the
/// pill when it was made to travel, and colour cannot identify it any more:
/// crossing an unassigned trip it is deliberately grey rather than the brand
/// red, so a colour finder loses it exactly during the journey these tests
/// exist to measure.
Rect underline(WidgetTester tester) {
final marks = find.byKey(TripTabs.markerKey);
expect(
marks,
findsOneWidget,
reason: 'exactly one tab may be marked at a time',
);
return tester.getRect(marks);
}
// The marker followed a separate "this trip is running" signal for a while,
// which left Trip 1 permanently underlined while the rider stood on Trip 2 —
// so a tap appeared to do nothing. The marker belongs to selection.
testWidgets('the underline sits under the selected tab', (tester) async {
await pumpTabs(tester, selected: 0);
final first = underline(tester);
final trip1 = tester.getRect(find.text('Trip 1'));
expect(
first.center.dx,
closeTo(trip1.center.dx, 2),
reason: 'the marker should be under Trip 1',
);
});
testWidgets('and travels when a different tab is picked', (tester) async {
await pumpTabs(tester, selected: 0);
final before = underline(tester).center.dx;
await pumpTabs(tester, selected: 1);
await tester.pumpAndSettle();
final after = underline(tester).center.dx;
final trip2 = tester.getRect(find.text('Trip 2'));
expect(
after,
greaterThan(before),
reason:
'the marker moved from $before to $after — it must follow '
'the selection rightward to Trip 2',
);
expect(after, closeTo(trip2.center.dx, 2));
});
testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', (
tester,
) async {
// The pill used to belong to each segment: one faded out on the right
// while a *different* one faded in on the left, so nothing ever occupied
// the space between and there was no object to cross Trip 2 with.
await pumpTabs(tester, selected: 2);
await tester.pumpAndSettle();
final trip2 = tester.getRect(find.text('Trip 2'));
final start = underline(tester).center.dx;
expect(
start,
greaterThan(trip2.center.dx),
reason: 'sanity: the marker starts to the right of Trip 2',
);
// `pumpTabs` pumps and settles; drive the frames by hand instead so the
// journey can be sampled.
await pumpTabsNoSettle(tester, selected: 0);
final samples = <double>[];
for (var elapsed = 0; elapsed < 420; elapsed += 20) {
await tester.pump(const Duration(milliseconds: 20));
samples.add(underline(tester).center.dx);
}
final half = trip2.width / 2;
expect(
samples.any((x) => (x - trip2.center.dx).abs() < half),
isTrue,
reason: 'the marker never passed over Trip 2; samples: $samples',
);
for (var i = 1; i < samples.length; i++) {
expect(
samples[i],
lessThanOrEqualTo(samples[i - 1] + 0.5),
reason: 'the marker moved backwards at sample $i: $samples',
);
}
await tester.pumpAndSettle();
expect(
underline(tester).center.dx,
closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2),
);
});
testWidgets('the pill is the brand tint, not a white card', (tester) async {
// It was a white pill with a drop shadow, which made this the one
// selected-state in the app that was not the brand colour — the two
// controls a rider uses most looked like they came from different apps.
await pumpTabs(tester, selected: 0);
final fills = tester
.widgetList<Container>(find.byType(Container))
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.toList();
final glass = ColorConstants.primary.withValues(alpha: 0.12);
expect(
fills.map((d) => d.color),
contains(glass),
reason:
'the selection pill should be the light glass red the bottom '
'nav bar uses',
);
expect(
fills.where(
(d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false),
),
isEmpty,
reason: 'no raised white pill should remain',
);
});
});
// ── Home has exactly two horizontal insets ──
//
// Bookings cards sit at 16 (`PickupCard` carries `margin: horizontal 16.w`
// and its list adds no horizontal padding). Home is wider on purpose: it is a
// denser screen — shift card, three stat cards, a pinned tab strip and a rail
// above the route — and at 16 the whole column read as pressed against the
// border.
//
// Pinned because it is the kind of value that drifts a couple of points at a
// time. What matters is that every element on the screen agrees on it: the
// cards, the tab strip and the accept bar were at 16, 20 and 18 respectively
// at one point, which is what a gutter looks like when nobody is checking.
// ── Select-all leads the list it selects ──
//
// It used to sit under the last hub row, a full screen of scrolling below the
// first stop on a six-stop trip — and it is the control the rider reaches for
// *before* reading the list, since "take the lot" is the common case.
group('select all', () {
List<Map<String, dynamic>> threeStops() => [stop('A'), stop('B'), stop('C')];
Future<void> pumpTrip(WidgetTester tester, {Set<String> picked = const {}}) {
return pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: threeStops()),
selectedIds: picked,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onAcceptSelected: (_) async {},
),
);
}
testWidgets('sits above the route, not below it', (tester) async {
await pumpTrip(tester);
final selectAll = find.text('Select all 3 stops');
expect(selectAll, findsOneWidget);
expect(
tester.getRect(selectAll).top,
lessThan(tester.getRect(find.text('START · HUB')).top),
reason: 'it heads the list; the route starts under it',
);
});
testWidgets('counts the picked stops as they are ticked', (tester) async {
await pumpTrip(tester, picked: const {'A', 'B'});
expect(find.text('2 of 3 selected'), findsOneWidget);
expect(find.text('ALL'), findsOneWidget);
});
testWidgets('offers a clear once everything is picked', (tester) async {
await pumpTrip(tester, picked: const {'A', 'B', 'C'});
expect(find.text('3 of 3 selected'), findsOneWidget);
expect(find.text('CLEAR'), findsOneWidget);
});
testWidgets('stays away when there is only one stop to pick',
(tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onToggleSelect: (_) {},
onSelectAll: (_) {},
onAcceptSelected: (_) async {},
),
);
expect(find.textContaining('Select all'), findsNothing);
});
});
// ── Finished work leaves the decision screen ──
//
// A completed stop used to stay in the list, greyed out with a "Completed"
// chip, and there is no bound on how many of those a shift produces. Six
// stops in, the rider was scrolling past six rows he could do nothing with to
// reach the one he could. They move to the Activity tab — but the *trip*
// keeps them, because its ring and its "N of M done" are counted from it.
group('completed stops', () {
testWidgets('are not listed, but still count towards the trip', (
tester,
) async {
final trip = Trip.fromStops(id: 't1', stops: [
{...stop('A'), 'orderstatus': 'picked'},
stop('B'),
]);
await pump(
tester,
TripCard(
trip: trip,
acceptedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
// Two stops in the trip, one row on screen.
expect(trip.stopCount, 2);
expect(find.text('Sri Balaji Stores'), findsOneWidget);
// And the brief still counts the finished one.
expect(find.text('1 of 2 stops done'), findsOneWidget);
});
testWidgets('the numbering does not renumber as stops finish', (
tester,
) async {
// Stop 1 is done, so the list starts at 2 — it does not slide up and
// call the second stop "1", which would rename a stop mid-shift.
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [
{...stop('A'), 'orderstatus': 'picked'},
stop('B'),
]),
acceptedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
expect(find.text('2'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
});
group('page gutter', () {
const homeGutter = 28.0;
const stopRowInset = 32.0;
/// The rounded surface a piece of text sits on: walk up from the text to the
/// nearest `Container` that actually paints a card.
Rect cardBehind(WidgetTester tester, String anchor) {
final ancestors = find
.ancestor(of: find.text(anchor), matching: find.byType(Container))
.evaluate();
for (final e in ancestors) {
final d = (e.widget as Container).decoration;
if (d is BoxDecoration && d.borderRadius != null) {
final box = e.renderObject as RenderBox;
final pos = box.localToGlobal(Offset.zero);
return Rect.fromLTWH(pos.dx, pos.dy, box.size.width, box.size.height);
}
}
fail('no card found behind "$anchor"');
}
testWidgets('the summary cards and the stop rows each keep their inset', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 340)]),
),
);
final screen = tester.getSize(find.byType(TripCard)).width;
// The summary is one surface now rather than four, so there is one card
// to measure instead of a list of captions — but it still has to land on
// the same gutter the footer bar uses. Found by its own decoration rather
// than by a caption, because every string on the face is data that moves
// with the trip.
final surface = find.descendant(
of: find.byType(TripBriefStrip),
matching: find.byWidgetPredicate(
(w) =>
w is Container &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).borderRadius != null,
),
);
final right = screen - tester.getRect(surface.first).right;
expect(
right,
closeTo(homeGutter, 0.5),
reason:
"the brief ends $right from the edge, but Home's gutter is "
'$homeGutter — the summary must sit on it',
);
// The stop rows are inset further, on purpose: the rail takes the first
// 30 points on their left, so matching the gutter on the right left them
// visibly lopsided inside their own row. They are indented list items
// under the summary, not more cards in the same column.
final stopCard = cardBehind(tester, 'Sri Balaji Stores');
final stopRight = screen - stopCard.right;
expect(
stopRight,
closeTo(stopRowInset, 0.5),
reason:
'the stop card ends $stopRight from the edge; it should be '
'inset to $stopRowInset rather than share the summary gutter',
);
expect(
stopRight,
greaterThan(homeGutter),
reason: 'the point of the inset is that it clears the border more',
);
});
testWidgets('the rail is inset as far as the card edge', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
),
);
// The route block indents as a whole: whatever the card's right edge
// clears, the rail's left edge clears too. Insetting one side only left
// the rail closer to the border than anything else on screen, so the row
// still read as pushed against the edge — just from the other side.
final node = tester.getRect(find.byType(TripCard));
final railLeft =
tester.getRect(find.text('1')).center.dx -
11; // half the 22pt node, back to its left edge
expect(
railLeft - node.left,
closeTo(stopRowInset, 1.5),
reason:
'the rail starts ${railLeft - node.left} in; the card ends '
'$stopRowInset in — an indented block indents on both sides',
);
});
});
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// The accent stripe on the Bookings stop card is a `Container` with a width
/// and no height, laid out by `Row(crossAxisAlignment: stretch)`. That idiom is
/// a trap inside a `ListView`: stretch sizes children to the Row's own
/// cross-axis extent, which in a scrollable is UNBOUNDED, so the stripe is
/// asked to be infinitely tall and the entire card fails to lay out.
///
/// It fails silently in the worst possible way — the progress rail above the
/// list is a separate widget reading the same data, so it kept showing the
/// right stop count while every card rendered as nothing. That looked exactly
/// like "the bookings data is broken", and three rounds of data fixes chased
/// it before the layout was suspected.
///
/// PickupCard itself is private to the `pickups.dart` library and cannot be
/// imported, so these tests pin the idiom rather than the widget.
void main() {
Widget inList(Widget child) => MaterialApp(
home: Scaffold(
body: ListView(children: [child]),
),
);
Widget stripeRow({required bool withIntrinsicHeight}) {
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: const Color(0xFF960019)),
const Expanded(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('Sri Balaji Stores\n19, Thadagam Road'),
),
),
],
);
return Container(
margin: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: withIntrinsicHeight ? IntrinsicHeight(child: row) : row,
),
);
}
testWidgets('a stretch Row with a height-less child breaks in a ListView',
(tester) async {
await tester.pumpWidget(inList(stripeRow(withIntrinsicHeight: false)));
expect(tester.takeException(), isNotNull,
reason: 'this is the shape that made every stop card disappear');
});
testWidgets('IntrinsicHeight gives stretch something finite to stretch to',
(tester) async {
await tester.pumpWidget(inList(stripeRow(withIntrinsicHeight: true)));
expect(tester.takeException(), isNull);
// And the card has real height — an invisible zero-height card would pass
// an exception check while still showing the rider nothing.
final size = tester.getSize(find.byType(IntrinsicHeight));
expect(size.height, greaterThan(20));
// The stripe spans the full card height rather than collapsing.
final stripe = tester.getSize(
find.descendant(
of: find.byType(IntrinsicHeight),
matching: find.byType(Container),
).first,
);
expect(stripe.height, size.height);
expect(stripe.width, 4);
});
}

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
/// The payment screen is the only place in the app where money changes hands,
/// and it is read by two people at once — the rider holding the phone and the
/// customer being asked to pay. So the things worth testing are the things
/// either of them would dispute afterwards:
///
/// • the amount comes from the booking and nowhere else;
/// • the page says which consignment is being paid for;
/// • the QR is on the page, carrying that amount, not in a dialog;
/// • a short payment cannot be recorded without a figure and a reason.
void main() {
Future<void> pump(
WidgetTester tester,
Map<String, dynamic> stop, {
Map<String, dynamic> verification = const {},
String? initialMethod,
double textScale = 1.0,
}) async {
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context)
.copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: CollectPaymentPage(
pickup: stop,
verificationData: verification,
initialMethod: initialMethod,
),
),
),
);
// Not pumpAndSettle: the slide-to-confirm control animates its hint
// forever, so a settle never comes. Two frames is enough for this page,
// which has no entrance animation of its own.
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
}
Map<String, dynamic> stopOf({double due = 450, double pickupAmt = 0}) =>
<String, dynamic>{
'orderid': 'DM-99120',
'type': 'pickup',
'pickupqty': '3',
'Quantity': '3',
'pickupcustomer': 'Priya Sharma',
'tenantname': 'Chettinad Textiles',
'pickupaddress': '12/3B, Second Cross Street, Ram Nagar, Coimbatore',
'collectionamt': '$due',
if (pickupAmt > 0) 'pickupamt': '$pickupAmt',
};
testWidgets('the amount is the booking\'s, and it names who owes it',
(tester) async {
await pump(tester, stopOf(due: 450));
expect(find.text('₹450'), findsOneWidget);
expect(find.text('from Priya Sharma'), findsOneWidget);
});
testWidgets('the page says what the money is for', (tester) async {
await pump(
tester,
stopOf(),
verification: {
'weight': '4.5',
'pickup': {'collected': 3},
},
);
// The consignment, the shipper it belongs to, and what was actually taken —
// this is the half of the screen a customer paying at his own door needs
// before he will trust the number above it.
expect(find.text('What this pays for'), findsOneWidget);
expect(find.text('DM-99120'), findsOneWidget);
expect(find.text('Chettinad Textiles'), findsOneWidget);
expect(find.text('3 collected'), findsOneWidget);
expect(find.text('4.5 kg'), findsOneWidget);
});
testWidgets('cash is the default, and it shows no QR', (tester) async {
await pump(tester, stopOf());
expect(find.byType(QrImageView), findsNothing);
expect(
find.textContaining('Slide — took ₹450 by Cash'),
findsOneWidget,
);
});
testWidgets('choosing UPI puts the QR on the page, not behind a dialog',
(tester) async {
await pump(tester, stopOf(due: 450));
expect(find.byType(QrImageView), findsNothing);
await tester.tap(find.text('UPI / QR'));
await tester.pump(const Duration(milliseconds: 300));
// On the page, next to the consignment it is settling — no dialog, and
// nothing hidden behind a further confirm.
expect(find.byType(QrImageView), findsOneWidget);
expect(find.text('₹450 to miler@upi'), findsOneWidget);
});
group('the UPI intent', () {
test('carries the amount, the payee and the consignment', () {
final uri = buildUpiUri(
payeeVpa: 'shop@okhdfcbank',
payeeName: 'Chettinad Textiles',
orderId: 'DM-99120',
amount: 450,
);
// The amount is baked in, so the customer cannot pay the wrong figure by
// mistyping it; the note makes the payment identifiable in his own
// statement weeks later.
expect(uri, contains('pa=shop%40okhdfcbank'));
expect(uri, contains('am=450.00'));
expect(uri, contains('cu=INR'));
expect(uri, contains(Uri.encodeComponent('Miler pickup DM-99120')));
});
test('escapes a payee name that would otherwise break the intent', () {
final uri = buildUpiUri(
payeeVpa: 'shop@upi',
payeeName: 'Ram & Co · Traders',
orderId: '',
amount: 99.5,
);
expect(uri, isNot(contains('&pn=Ram & Co')));
expect(uri, contains('am=99.50'));
expect(uri, contains(Uri.encodeComponent('Miler pickup')));
});
});
testWidgets('the caller can open straight onto UPI', (tester) async {
// The Bookings sheet already asked which method; asking again on arrival
// would be the extra tap this page exists to remove.
await pump(tester, stopOf(), initialMethod: 'upi (₹450)');
expect(find.byType(QrImageView), findsOneWidget);
});
testWidgets('a short payment needs a figure and a reason', (tester) async {
await pump(tester, stopOf(due: 450));
await tester.ensureVisible(find.text('Paid less'));
await tester.pump();
await tester.tap(find.text('Paid less'));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Enter how much you actually took'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '400');
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Say why the customer paid less'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'No change');
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('₹50 still owed on this consignment'), findsOneWidget);
});
testWidgets('a separate pickup charge is listed, never added up',
(tester) async {
// The office sets what is due. The page explains it; it does not do
// arithmetic the office has not sanctioned.
await pump(tester, stopOf(due: 450, pickupAmt: 60));
expect(find.text('Pickup charge'), findsOneWidget);
expect(find.text('₹60'), findsOneWidget);
expect(find.text('Due now'), findsOneWidget);
expect(find.text('₹510'), findsNothing);
});
testWidgets('a prepaid stop is not a payment screen at all', (tester) async {
await pump(tester, <String, dynamic>{
'orderid': 'DM-1',
'pickupcustomer': 'Priya Sharma',
});
expect(find.text('Nothing to collect here'), findsOneWidget);
expect(find.byType(QrImageView), findsNothing);
});
for (final scale in const [1.0, 1.5, 2.0]) {
testWidgets('lays out at ${scale}x text scale', (tester) async {
await pump(tester, stopOf(due: 1250, pickupAmt: 60), textScale: scale);
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text('UPI / QR'));
await tester.pump();
await tester.tap(find.text('UPI / QR'));
await tester.pump(const Duration(milliseconds: 300));
expect(tester.takeException(), isNull);
});
}
}

View File

@@ -0,0 +1,143 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/accepted_store.dart';
/// Finishing a stop is optimistic — the rider is moved on whether or not the
/// status call lands, and the queue endpoints will not report the stop as
/// finished for a poll or two (never, on demo data). This store is the only
/// record in between, and the Activity tab is empty without it.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Map<String, dynamic> booking(String id) => {
'orderid': id,
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Coimbatore',
};
String today() {
final t = DateTime.now();
return '${t.year}-${t.month.toString().padLeft(2, '0')}-'
'${t.day.toString().padLeft(2, '0')}';
}
setUp(() => SharedPreferences.setMockInitialValues({}));
test('a finished stop is readable, and reads as finished', () async {
await addCompletedBookings([booking('A')]);
final done = await getCompletedBookings();
expect(done, hasLength(1));
expect(done.first['orderid'], 'A');
expect(
stopStatusOf(done.first).isFinishedPickup,
isTrue,
reason: 'Activity filters on the same test Bookings drops a stop with',
);
expect(done.first['completedat'], isNotNull);
});
test('a cancelled stop is stored as cancelled, not picked', () async {
await addCompletedBookings([booking('B')], cancelled: true);
final done = await getCompletedBookings();
expect(done.first['orderstatus'], 'cancelled');
expect(stopStatusOf(done.first).isCancelled, isTrue);
});
test('newest first', () async {
await addCompletedBookings([booking('A')]);
await Future<void>.delayed(const Duration(milliseconds: 5));
await addCompletedBookings([booking('B')]);
final done = await getCompletedBookings();
expect(done.map((b) => b['orderid']).toList(), ['B', 'A']);
});
test('the same stop twice is one row', () async {
await addCompletedBookings([booking('A')]);
await addCompletedBookings([booking('A')]);
expect(await getCompletedBookings(), hasLength(1));
});
// ── The timings ──
//
// Activity's stop detail can only answer "when did I get there" and "how long
// did that door take" if the two stamps survive the stop being closed. They
// live in prefs keys the *next* stop overwrites, so completion is the only
// moment they can be captured — and capturing a stale one is worse than
// capturing none, because another stop's clock reads as fact.
group('timings', () {
Map<String, dynamic> withPickup(String id) =>
{...booking(id), 'pickupid': '77'};
test('the departure and arrival stamps are carried onto the record',
() async {
SharedPreferences.setMockInitialValues({
kStopStartedKey('77'): '2026-08-06T09:12:00.000',
kStopArrivedKey('77'): '2026-08-06T09:31:00.000',
});
await addCompletedBookings([withPickup('A')]);
final done = await getCompletedBookings();
expect(done.single['startedat'], '2026-08-06T09:12:00.000');
expect(done.single['arrivedat'], '2026-08-06T09:31:00.000');
});
test('the keys are cleared, so the next stop cannot inherit them',
() async {
SharedPreferences.setMockInitialValues({
kStopStartedKey('77'): '2026-08-06T09:12:00.000',
kStopArrivedKey('77'): '2026-08-06T09:31:00.000',
});
await addCompletedBookings([withPickup('A')]);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(kStopStartedKey('77')), isNull);
expect(prefs.getString(kStopArrivedKey('77')), isNull);
// A second stop sharing the pickup id — a resumed skip worked twice in
// one day — must not report the first attempt's clock.
await addCompletedBookings([withPickup('B')]);
final second = (await getCompletedBookings())
.firstWhere((b) => b['orderid'] == 'B');
expect(second['startedat'], isNull);
});
test('a stop closed without ever opening the map claims no timings',
() async {
// Bulk-completed from Home: nothing wrote a departure, so nothing is
// claimed, and the detail panel says "not recorded" instead of inventing
// a figure that cannot be caught.
await addCompletedBookings([withPickup('A')]);
final done = await getCompletedBookings();
expect(done.single['startedat'], isNull);
expect(done.single['arrivedat'], isNull);
});
});
// The store is per-day: yesterday's work is not today's activity, and a store
// that never forgets grows for the life of the install.
test('yesterday is dropped, today is kept', () async {
SharedPreferences.setMockInitialValues({
'completed_bookings': jsonEncode([
{...booking('OLD'), 'completedday': '2020-01-01', 'orderstatus': 'picked'},
{...booking('NEW'), 'completedday': today(), 'orderstatus': 'picked'},
]),
});
final done = await getCompletedBookings();
expect(done.map((b) => b['orderid']).toList(), ['NEW']);
// And the prune is written back, not recomputed on every read.
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('completed_bookings'), isNot(contains('OLD')));
});
}

View File

@@ -0,0 +1,290 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ── Contrast is a contract, not a preference ──
///
/// This app is read on a phone clamped to a handlebar in Indian daylight, by
/// someone wearing a helmet, while a customer waits. Contrast is not an
/// accessibility checkbox here; it is whether the rider can do the job.
///
/// An audit of the palette found the app's **second most-used colour failing
/// outright**: `secondaryText` was #8E8E93, which is 3.26:1 on white against a
/// 4.5:1 WCAG AA floor for normal text. It carries captions, labels and
/// metadata on 89 call sites across all 33 screens — so on every screen, the
/// supporting text was the part that disappeared in sunlight. `moneyGreen`
/// exists for the same reason: `acceptGreen` is 2.62:1 as text.
///
/// Those were fixed at the token, so this file guards the tokens. A colour is a
/// number; a number can be asserted, and that is the only way a palette stays
/// legible through future redesigns.
void main() {
/// WCAG 2.1 relative luminance.
double luminance(Color c) {
double channel(double v) =>
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4) as double;
return 0.2126 * channel(c.r) +
0.7152 * channel(c.g) +
0.0722 * channel(c.b);
}
/// WCAG 2.1 contrast ratio, 1.0–21.0.
double ratio(Color a, Color b) {
final la = luminance(a), lb = luminance(b);
return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05);
}
// Every background the app draws text on.
final surfaces = <String, Color>{
'white': Colors.white,
'page (surface)': ColorConstants.surface,
'card fill (cardSurface)': ColorConstants.cardSurface,
'neutralLight': ColorConstants.neutralLight,
};
/// WCAG AA: 4.5:1 for normal text, 3.0:1 for large or bold text.
const aaNormal = 4.5;
const aaLarge = 3.0;
group('text colours clear AA on every surface they are used on', () {
final bodyText = <String, Color>{
'slateText': ColorConstants.slateText,
'secondaryText': ColorConstants.secondaryText,
'onSurfaceVariant': ColorConstants.onSurfaceVariant,
'onSurface': ColorConstants.onSurface,
'moneyGreen': ColorConstants.moneyGreen,
'errorRed': ColorConstants.errorRed,
};
bodyText.forEach((name, colour) {
surfaces.forEach((bgName, bg) {
test('$name on $bgName', () {
final r = ratio(colour, bg);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: '$name on $bgName is ${r.toStringAsFixed(2)}:1, '
'under the $aaNormal:1 AA floor for normal text');
});
});
});
});
/// ── Every token is declared, or the test fails ──
///
/// The old version guarded six colours. That was a false sense of coverage:
/// an audit found **five tokens failing outright** — `warningOrange` 2.20:1,
/// `successGreen` 2.22:1, `warningYellow` 1.73:1, `cardBlue` 2.77:1,
/// `infoBlue` 4.02:1 — plus `disabledText` at 1.68:1 being used as readable
/// caption text on seven screens. Every one of them sat outside the six.
///
/// So the contract is inverted. This enumerates the whole palette and forces
/// each colour into exactly one bucket:
///
/// * **text-safe** — asserted ≥4.5:1 on every surface the app draws on.
/// * **fill** — carries a white label; the label is what is asserted.
/// * **structural** — a hairline or a disabled fill, carrying no words:
/// asserted visible against the surface, and asserted *not* readable, so a
/// colour that could be text has to be filed as text.
///
/// A new token belongs to a bucket or this test fails, which is the only way
/// the next `warningOrange` cannot land.
group('the palette is exhaustively classified', () {
// Colours the app is allowed to render words in.
final textSafe = <String, Color>{
'slateText': ColorConstants.slateText,
'secondaryText': ColorConstants.secondaryText,
'onSurfaceVariant': ColorConstants.onSurfaceVariant,
'onSurface': ColorConstants.onSurface,
'moneyGreen': ColorConstants.moneyGreen,
'acceptGreen': ColorConstants.acceptGreen,
'errorRed': ColorConstants.errorRed,
'error': ColorConstants.error,
'warning': ColorConstants.warning,
'primary': ColorConstants.primary,
'rewardAmber': ColorConstants.rewardAmber,
'tertiary': ColorConstants.tertiary,
'deliveryAccent': ColorConstants.deliveryAccent,
'combinedAccent': ColorConstants.combinedAccent,
'upiPurple': ColorConstants.upiPurple,
'emphasis': ColorConstants.emphasis,
};
// Colours that fill a shape carrying a white label. The label is what is
// asserted — several of these are dark enough to read as text too, which is
// fine; the bucket is about the job, not about failing some other job.
final fillWithWhiteLabel = <String, Color>{
'pickupChip': ColorConstants.pickupChip,
'deliveryChip': ColorConstants.deliveryChip,
'combinedChip': ColorConstants.combinedChip,
'primaryContainer': ColorConstants.primaryContainer,
'successContainer': ColorConstants.successContainer,
'tertiaryContainer': ColorConstants.tertiaryContainer,
};
// Hairlines, dividers and disabled fills. These carry no words at all; the
// only thing that matters is that they are visible against the surface
// behind them.
final structural = <String, Color>{
'disabledFill': ColorConstants.disabledFill,
'borderStrong': ColorConstants.borderStrong,
'borderSubtle': ColorConstants.borderSubtle,
'dividerColor': ColorConstants.dividerColor,
'outlineVariant': ColorConstants.outlineVariant,
};
textSafe.forEach((name, colour) {
surfaces.forEach((bgName, bg) {
test('text-safe: $name reads on $bgName', () {
final r = ratio(colour, bg);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: '$name on $bgName is ${r.toStringAsFixed(2)}:1. Either '
'lift the colour or move it out of the text-safe list — a '
'token that is in no list is how #FF9500 ended up carrying '
'the retry notice at 2.20:1');
});
});
});
fillWithWhiteLabel.forEach((name, fill) {
test('fill: $name carries a white label', () {
final r = ratio(Colors.white, fill);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: 'white on $name is ${r.toStringAsFixed(2)}:1');
});
});
structural.forEach((name, colour) {
test('structural: $name is visible on white', () {
final r = ratio(colour, Colors.white);
// A 1px hairline is not held to a text floor — iOS separators sit
// around 1.1:1 and are the convention riders already read. What is
// asserted is that the difference is real and cannot erode to nothing.
// `borderStrong` is held higher, separately, because its job is to make
// a container read as a container in sunlight.
expect(r, greaterThan(1.15),
reason: '$name is ${r.toStringAsFixed(3)}:1 on white — an edge '
'nobody can see');
});
test('structural: $name is never a text colour', () {
// It cannot pass as text, which is exactly why it must not be used as
// one. `disabledFill` was, on seven screens, at 1.68:1.
final r = ratio(colour, Colors.white);
expect(r, lessThan(aaNormal),
reason: '$name reads well enough to be text — if that is what it '
'is for, move it to the text-safe list');
});
});
test('the five retired tokens have not come back', () {
// Compile-time guard. If someone re-adds `warningOrange`, `infoBlue`,
// `successGreen`, `cardBlue` or `warningYellow`, they have to delete this
// list first — and read why they were removed.
const retired = <String, int>{
'warningOrange': 0xFFFF9500,
'successGreen': 0xFF34C759,
'warningYellow': 0xFFFFB800,
'cardBlue': 0xFF0EA5E9,
'infoBlue': 0xFF007AFF,
};
for (final e in retired.entries) {
final r = ratio(Color(e.value), Colors.white);
expect(r, lessThan(aaNormal),
reason: 'sanity: ${e.key} really did fail at '
'${r.toStringAsFixed(2)}:1');
}
});
});
group('white text on a filled control clears AA', () {
final fills = <String, Color>{
'primary (brand)': ColorConstants.primary,
'acceptGreen (Accept button)': ColorConstants.acceptGreen,
'pickupChip': ColorConstants.pickupChip,
'deliveryChip': ColorConstants.deliveryChip,
'combinedChip': ColorConstants.combinedChip,
'warning (skipped)': ColorConstants.warning,
'errorRed': ColorConstants.errorRed,
};
fills.forEach((name, fill) {
test('white on $name', () {
final r = ratio(Colors.white, fill);
// Button and chip labels are bold, so the large-text floor applies —
// but everything here should be comfortably past it.
expect(r, greaterThanOrEqualTo(aaLarge),
reason: 'white on $name is ${r.toStringAsFixed(2)}:1');
});
});
// The chips carry 10sp text, which is small even when bold, so they are held
// to the stricter floor. This is what "darkened" bought.
test('the stop-type chips clear the NORMAL-text floor, not just large', () {
for (final e in {
'pickupChip': ColorConstants.pickupChip,
'deliveryChip': ColorConstants.deliveryChip,
'combinedChip': ColorConstants.combinedChip,
}.entries) {
final r = ratio(Colors.white, e.value);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: '${e.key} is ${r.toStringAsFixed(2)}:1 — the chip label is '
'10sp, so large-text allowances do not apply');
}
});
});
group('surfaces stay distinguishable', () {
// A borderless card has nothing but its fill to separate it from the page.
// Riders reported being unable to tell cards apart at ~3%; this keeps the
// separation from quietly eroding back to that.
test('cardSurface is clearly darker than the page', () {
final drop = 1 -
luminance(ColorConstants.cardSurface) /
luminance(ColorConstants.surface);
expect(drop, greaterThan(0.07),
reason: 'only ${(drop * 100).toStringAsFixed(1)}% darker than the '
'page — a borderless card needs its fill to do the separating');
});
test('borderStrong is visible against white and against the card', () {
// Non-text contrast (UI components) has a 3:1 AA floor.
for (final e in {
'white': Colors.white,
'cardSurface': ColorConstants.cardSurface,
}.entries) {
final r = ratio(ColorConstants.borderStrong, e.value);
expect(r, greaterThan(1.2),
reason: 'borderStrong on ${e.key} is only '
'${r.toStringAsFixed(2)}:1 — an edge nobody can see');
}
});
});
group('the brand palette has no tonal leftovers', () {
// The M3 tonal derivation of the red seed produced a brown body text and a
// pink outline, which shipped on 70 call sites and inside `AppTheme` — so
// the auth flow and every text field were brown-and-pink while the
// dashboard was slate-and-white. These assert they stay neutral: for a
// grey/slate, the three channels sit close together.
void expectNeutral(String name, Color c) {
final spread = [c.r, c.g, c.b].reduce(math.max) -
[c.r, c.g, c.b].reduce(math.min);
expect(spread, lessThan(0.12),
reason: '$name has an RGB spread of ${spread.toStringAsFixed(3)} — '
'that is a hue, not a neutral');
}
test('onSurfaceVariant is a neutral, not a brown', () {
expectNeutral('onSurfaceVariant', ColorConstants.onSurfaceVariant);
});
test('outlineVariant is a neutral, not a pink', () {
expectNeutral('outlineVariant', ColorConstants.outlineVariant);
});
test('secondaryText is a neutral', () {
expectNeutral('secondaryText', ColorConstants.secondaryText);
});
});
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart';
import 'fixtures/demo_stops.dart';
/// Injecting demo stops into Bookings is only half the fix. The tab drops
/// anything still pending — it shows work the rider has *taken on* — so demo
/// stops that arrive as `status: 'new'` are filtered out silently and the tab
/// stays empty anyway. These tests pin the states, because a regression there
/// looks exactly like "the mock data doesn't work" with nothing in the logs.
void main() {
/// The predicate Bookings uses to decide a stop belongs to it at all
/// (pickups.dart — anything still pending lives on Home instead).
bool survivesBookingsFilter(Map<String, dynamic> o) {
final status = (o['orderstatus']?.toString().toLowerCase() ?? '').trim();
const pending = {'', 'assigned', 'miler_assigned', 'pending', 'new'};
return !pending.contains(status);
}
test('every demo booking survives the accepted-only filter', () {
final bookings = getDemoBookings(riderId: 4021);
expect(bookings, isNotEmpty);
expect(
bookings.where(survivesBookingsFilter).length,
bookings.length,
reason: 'a demo stop left as "new" is dropped and never reaches the list',
);
});
test('covers every state the tab can render', () {
final statuses = getDemoBookings(riderId: 4021)
.map((b) => stopStatusFromRaw(b['orderstatus']))
.toSet();
// Two finished ones give the progress bar something green; the active one
// drives the banner and blocks the rest; the skipped one is the amber
// return-visit case.
expect(statuses, contains(StopStatus.picked));
expect(statuses, contains(StopStatus.active));
expect(statuses, contains(StopStatus.skipped));
expect(statuses, contains(StopStatus.accepted));
});
test('leaves enough open stops to fill the list', () {
// Bookings excludes active and finished stops from the card list — those
// go to the banner and the progress count. What is left has to be more
// than a couple of cards or the tab still looks broken.
final open = getDemoBookings(riderId: 4021).where((b) {
final st = stopStatusFromRaw(b['orderstatus']);
return st != StopStatus.active &&
st != StopStatus.picked &&
st != StopStatus.cancelled;
}).length;
expect(open, greaterThanOrEqualTo(8));
});
test('is the same route Home gave the miler', () {
final home = getDemoTripStops(riderId: 4021).take(12);
final bookings = getDemoBookings(riderId: 4021);
expect(
bookings.map((b) => b['orderid']),
home.map((b) => b['orderid']),
reason: 'Bookings must not invent a second, unrelated route',
);
});
pipelineTests();
test('two milers get different bookings', () {
final a = getDemoBookings(riderId: 4021).map((b) => b['orderid']).toSet();
final b = getDemoBookings(riderId: 4022).map((b) => b['orderid']).toSet();
expect(a.intersection(b), isEmpty);
});
}
/// The first fix for the empty Bookings tab was gated on the RAW api response
/// (`items.isEmpty`), but the tab renders a list that survives two more
/// filters. A signed-in miler's backend answer is rarely empty — it is full of
/// ASSIGNED bookings, which belong on Home and are filtered out here — so the
/// fallback never fired and the tab stayed blank. These tests pin the end of
/// the pipeline, using the real production helpers, not a paraphrase of them.
void pipelineTests() {
/// Bookings' accepted-only predicate.
List<Map<String, dynamic>> keepWorkable(
List<Map<String, dynamic>> items,
Set<String> acceptedIds,
) =>
items.where((o) {
final id = (o['orderid'] ?? '').toString();
if (id.isNotEmpty && acceptedIds.contains(id)) return true;
final status =
(o['orderstatus']?.toString().toLowerCase() ?? '').trim();
const pending = {'', 'assigned', 'miler_assigned', 'pending', 'new'};
return !pending.contains(status);
}).toList();
test('a backend full of ASSIGNED bookings still leaves the tab empty', () {
// What the live backend actually returns for a seeded miler.
final fromApi = [
for (var i = 0; i < 6; i++)
<String, dynamic>{'orderid': 'API-$i', 'orderstatus': 'miler_assigned'},
];
expect(fromApi, isNotEmpty, reason: 'the raw response is NOT empty');
expect(keepWorkable(fromApi, {}), isEmpty,
reason: 'yet nothing survives — which is why the old gate never fired');
});
test('the fallback keyed on the filtered list produces visible cards', () {
final fromApi = [
for (var i = 0; i < 6; i++)
<String, dynamic>{'orderid': 'API-$i', 'orderstatus': 'miler_assigned'},
];
var workable = keepWorkable(fromApi, {});
if (workable.isEmpty) workable = getDemoBookings(riderId: 4021);
// Bookings' list excludes active and finished stops — those go to the
// banner and the progress count.
final listForTop = workable.where((o) {
final st = stopStatusFromRaw(o['orderstatus']);
return st != StopStatus.active &&
st != StopStatus.picked &&
st != StopStatus.cancelled;
}).toList();
expect(listForTop, isNotEmpty, reason: 'the rider must see cards');
expect(listForTop.length, greaterThanOrEqualTo(8));
});
}

234
test/done_handoff_test.dart Normal file
View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/pickups/pickups.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ── The handoff screen ranks by type, not by boxes ──
///
/// Every group on this screen used to be a bordered, filled, rounded rectangle:
/// a green result banner, a shadowed hero card, a bordered list card with a
/// bordered pill inside it and a filled badge inside that. Four containers and a
/// shadow, down a page holding about forty words.
///
/// The cost was not ugliness, it was ranking. A border says "this is separate
/// from its neighbours"; give that to all four groups and none of them is
/// emphasised, so the rider's eye lands on whichever box is biggest rather than
/// on the one fact he opened the screen for — where he is going next.
///
/// The redesign carries rank with type size, colour weight and whitespace, and
/// spends its one border on the single change of subject. These tests pin that,
/// because a comment asking for restraint does not survive the next redesign.
void main() {
Map<String, dynamic> stop(
String id,
String name,
String address, {
required String type,
String kms = '',
}) => {
'orderid': id,
'type': type,
'pickupcustomer': name,
'pickupaddress': address,
'kms': kms,
'orderstatus': 'accepted',
};
final route = [
stop(
'1',
'Sundar Textiles',
'78, Cross Cut Road, Gandhipuram, Coimbatore',
type: 'combined',
kms: '2.4',
),
stop(
'2',
'Green Leaf Grocers',
'29, Kovaipudur Main Road, Coimbatore',
type: 'delivery',
),
stop(
'3',
'Ganesh Traders',
'134, Avinashi Road, Hope College, Coimbatore',
type: 'pickup',
),
];
Future<void> pumpDone(WidgetTester tester, {double topInset = 24}) async {
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
tester.view.padding = FakeViewPadding(top: topInset * 3, bottom: 16 * 3);
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
minTextAdapt: true,
builder: (_, _) => MaterialApp(
home: PickupsDone(isDelivery: true, remainingOrders: route),
),
),
);
// Past the 200ms entrance delay and the 800ms controller.
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 900));
}
double sizeOf(WidgetTester tester, String text) =>
tester.widget<Text>(find.text(text)).style!.fontSize!;
testWidgets('the next stop is the largest thing on the screen', (
tester,
) async {
await pumpDone(tester);
final hero = sizeOf(tester, 'Sundar Textiles');
// Against everything else the screen says.
expect(hero, greaterThan(sizeOf(tester, 'NEXT STOP')));
expect(
hero,
greaterThan(sizeOf(tester, '78, Cross Cut Road, Gandhipuram, Coimbatore')),
);
expect(hero, greaterThan(sizeOf(tester, 'Green Leaf Grocers')));
expect(hero, greaterThan(sizeOf(tester, 'Delivered · 3 stops left')));
// And by a margin wide enough to be seen rather than measured: the point is
// that the rider lands on it first, not that it happens to be a point
// bigger than the row beneath it.
expect(hero / sizeOf(tester, 'Green Leaf Grocers'), greaterThan(1.6));
});
testWidgets('finished work is quiet — grey text, green only in the tick', (
tester,
) async {
await pumpDone(tester);
final status = tester.widget<Text>(find.text('Delivered · 3 stops left'));
// Not the success colour. "Delivered" is something the rider did himself
// thirty seconds ago; he needs to be able to confirm it, not be told it.
expect(status.style!.color, ColorConstants.secondaryText);
// The tick is the whole of the green.
final tick = tester.widget<Icon>(
find.byIcon(Icons.check_circle_rounded).first,
);
expect(tick.color, ColorConstants.acceptGreen);
// The live destination, by contrast, is near-black.
expect(
tester.widget<Text>(find.text('Sundar Textiles')).style!.color,
ColorConstants.slateText,
);
});
testWidgets('nothing in the body is boxed', (tester) async {
await pumpDone(tester);
// Every decorated shape inside the scrolling content — the type dot, the
// numbered circles, the one hairline. None of them may carry a border or a
// shadow, which is what turns a mark into a card.
final decorations = tester
.widgetList<Container>(
find.descendant(
of: find.byType(SingleChildScrollView),
matching: find.byType(Container),
),
)
.map((c) => c.decoration)
.whereType<BoxDecoration>();
expect(decorations, isNotEmpty, reason: 'sanity: the dots are Containers');
for (final d in decorations) {
expect(d.border, isNull);
expect(d.boxShadow ?? const [], isEmpty);
}
});
testWidgets('the upcoming stops are one list, not a card each', (
tester,
) async {
await pumpDone(tester);
expect(find.text('UP NEXT · 2 STOPS'), findsOneWidget);
// Both rows present, numbered from their position on the route, each one
// tappable straight into that stop.
for (final row in const ['Green Leaf Grocers', 'Ganesh Traders']) {
expect(find.text(row), findsOneWidget);
expect(
find.ancestor(of: find.text(row), matching: find.byType(InkWell)),
findsOneWidget,
);
}
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.byIcon(Icons.chevron_right_rounded), findsNWidgets(2));
});
testWidgets('the stop type is a label, not a filled badge', (tester) async {
await pumpDone(tester);
// Sentence case and in the accent, under the address — not an all-caps
// chip floated above the name it is an attribute of.
final label = tester.widget<Text>(find.text('Pickup & Delivery'));
expect(label.style!.color, ColorConstants.combinedAccent);
expect(label.style!.fontSize, lessThan(sizeOf(tester, 'Sundar Textiles')));
expect(find.text('PICKUP & DELIVERY'), findsNothing);
});
testWidgets('the page has a top edge of its own', (tester) async {
// This screen carries no app bar — there is nowhere to go back to, so there
// is nothing for one to hold. `SafeArea` alone only clears the status bar,
// which left the first line of the page sitting hard under the clock while
// every screen in the app that *does* have a bar starts its content around
// 90pt down. The handoff read as the one page missing its header rather
// than as the one page that does not need one.
for (final inset in const [24.0, 47.0]) {
await pumpDone(tester, topInset: inset);
final status = tester.getRect(find.text('Delivered · 3 stops left'));
expect(
status.top - inset,
greaterThan(28.0),
reason: 'needs a real margin below the safe area, not just the inset',
);
}
});
testWidgets('the status line belongs to the top, not to the hero', (
tester,
) async {
await pumpDone(tester);
// Asymmetric on purpose. Equal gaps above and below left the completion
// line hanging between the top edge and the destination, belonging to
// neither; tighter above groups it with the top of the page, which is what
// it is — a footnote on the stop the rider just left.
final status = tester.getRect(find.text('Delivered · 3 stops left'));
final eyebrow = tester.getRect(find.text('NEXT STOP'));
final above = status.top - 24.0;
final below = eyebrow.top - status.bottom;
expect(below, greaterThan(above));
});
testWidgets('the CTA is pinned, green and thumb-sized', (tester) async {
await pumpDone(tester);
expect(find.text('Move to next stop'), findsOneWidget);
final button = tester.widget<MilerButton>(find.byType(MilerButton));
expect(button.color, ColorConstants.acceptGreen);
expect(button.height, greaterThanOrEqualTo(56.0));
// Sitting on the bottom edge, below the scrolling content.
final cta = tester.getRect(find.byType(MilerButton));
final body = tester.getRect(find.byType(SingleChildScrollView));
expect(cta.top, greaterThanOrEqualTo(body.bottom));
expect(cta.bottom, lessThanOrEqualTo(844.0));
});
}

118
test/duty_sheet_test.dart Normal file
View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:miler/views/Dashboard/home/duty_sheet.dart';
/// The duty sheet used to say one thing — "You will stop receiving bookings" —
/// and leave out the two that actually catch a rider out: tracking stops, and
/// the app will refuse to take him off duty while he is still holding work. It
/// also carried a wifi glyph, which says "network problem" on a screen about
/// whether he is working.
void main() {
Future<void> pump(
WidgetTester tester, {
required bool goingOnline,
Future<bool> Function()? onConfirm,
}) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: DutySheet(
goingOnline: goingOnline,
onConfirm: onConfirm ?? () async => true,
),
),
),
),
),
);
// The hint arrow loops forever, so there is nothing to settle into.
await tester.pump(const Duration(milliseconds: 300));
}
testWidgets('going on duty names the state it is heading for', (
tester,
) async {
await pump(tester, goingOnline: true);
expect(find.text('Go on duty?'), findsOneWidget);
// The strip shows the move: leaving Off duty, arriving at On duty.
expect(find.text('Off duty'), findsOneWidget);
expect(find.text('On duty'), findsOneWidget);
expect(find.text('Slide to go on duty'), findsOneWidget);
// Duty is not connectivity.
expect(find.byIcon(Icons.wifi), findsNothing);
expect(find.byIcon(Icons.wifi_off), findsNothing);
});
testWidgets('going off duty lists what stops, including the refusal', (
tester,
) async {
await pump(tester, goingOnline: false);
expect(find.text('Go off duty?'), findsOneWidget);
expect(find.text('No new trips until you are back on'), findsOneWidget);
expect(find.text('Live tracking pauses'), findsOneWidget);
// The one the old sheet sprang on him only after he had slid it.
expect(
find.text('Any booking you are holding must be finished first'),
findsOneWidget,
);
});
testWidgets('a completed slide runs the toggle', (tester) async {
var calls = 0;
await pump(
tester,
goingOnline: true,
onConfirm: () async {
calls++;
return true;
},
);
// The knob, not the arrow in the state strip above it.
final knob = find.descendant(
of: find.byType(SlideToSubmit),
matching: find.byIcon(Icons.arrow_forward_rounded),
);
await tester.drag(knob, const Offset(400, 0));
await tester.pump();
await tester.pump();
expect(calls, 1);
});
testWidgets('a short slide does not', (tester) async {
var calls = 0;
await pump(
tester,
goingOnline: false,
onConfirm: () async {
calls++;
return true;
},
);
final knob = find.descendant(
of: find.byType(SlideToSubmit),
matching: find.byIcon(Icons.arrow_forward_rounded),
);
await tester.drag(knob, const Offset(40, 0));
await tester.pump();
await tester.pump();
expect(calls, 0, reason: 'ending the day takes a deliberate gesture');
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

842
test/fixtures/demo_stops.dart vendored Normal file
View File

@@ -0,0 +1,842 @@
// Shared mock booking data for development/testing
List<Map<String, dynamic>> getMockQueues() {
final now = DateTime.now();
return [
{
'orderid': 'MOCK-Q-1001',
'pickupid': 'del-mock-q-1001',
'orderheaderid': '10001',
'orderstatus': 'accepted',
'pickupcustomer': 'Priya Sharma',
'pickupaddress': '45, Lake View Apartments, Race Course',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9558',
'pickuplat': '11.0243',
'pickuplong': '76.9652',
'kms': '2.1',
'Quantity': '3',
'notes': 'Documents parcel - handover at door',
'expected_pickup_time': now
.add(const Duration(minutes: 15))
.toIso8601String(),
'riderslat': '11.0180',
'riderslon': '76.9580',
},
{
'orderid': 'MOCK-Q-1002',
'pickupid': 'del-mock-q-1002',
'orderheaderid': '10002',
'orderstatus': '',
'pickupcustomer': 'Arun Venkatesh',
'pickupaddress': '23, Teachers Colony, Saibaba Colony',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9678',
'pickuplat': '11.0312',
'pickuplong': '76.9721',
'kms': '1.5',
'Quantity': '8',
'notes': 'Clothes & accessories - packed in bags',
'expected_pickup_time': now
.add(const Duration(minutes: 30))
.toIso8601String(),
'riderslat': '11.0180',
'riderslon': '76.9580',
},
{
'orderid': 'MOCK-Q-1003',
'pickupid': 'del-mock-q-1003',
'orderheaderid': '10003',
'orderstatus': '',
'pickupcustomer': 'Meena Krishnan',
'pickupaddress': '5/2, North Avenue, Vadavalli',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9902',
'pickuplat': '11.0358',
'pickuplong': '76.9985',
'kms': '3.2',
'Quantity': '12',
'notes': 'Electronics item - handle with care',
'expected_pickup_time': now
.add(const Duration(minutes: 45))
.toIso8601String(),
'riderslat': '11.0180',
'riderslon': '76.9580',
},
{
'orderid': 'MOCK-Q-1004',
'pickupid': 'del-mock-q-1004',
'orderheaderid': '10004',
'orderstatus': 'accepted',
'pickupcustomer': 'Vikram Singh',
'pickupaddress': '88, Royal Nagar, Singanallur',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9428',
'pickuplat': '11.0198',
'pickuplong': '76.9512',
'kms': '1.8',
'Quantity': '5',
'notes': 'Gift items - fragile, handle gently',
'expected_pickup_time': now
.add(const Duration(minutes: 20))
.toIso8601String(),
'riderslat': '11.0180',
'riderslon': '76.9580',
},
{
'orderid': 'MOCK-Q-1005',
'pickupid': 'del-mock-q-1005',
'orderheaderid': '10005',
'orderstatus': '',
'pickupcustomer': 'Divya Menon',
'pickupaddress': '120, West Street, Thudiyalur',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9889',
'pickuplat': '11.0318',
'pickuplong': '76.9965',
'kms': '2.8',
'Quantity': '15',
'notes': 'Books and stationery - secure packaging',
'expected_pickup_time': now
.add(const Duration(minutes: 60))
.toIso8601String(),
'riderslat': '11.0180',
'riderslon': '76.9580',
},
];
}
List<Map<String, dynamic>> getMockBookingPageItems() {
final now = DateTime.now();
return [
{
'orderid': 'MOCK-B-3001',
'pickupid': '3001',
'orderheaderid': '103001',
'orderstatus': 'accepted',
'step': 1,
'pickupcustomer': 'Lakshmi Narayanan',
'pickupaddress': '67, East Gate, Uppilipalayam',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9500',
'pickuplat': '11.0168',
'pickuplong': '76.9558',
'droplat': '11.0168',
'droplon': '76.9558',
'riderslat': '11.0180',
'riderslon': '76.9580',
'kms': '2.4',
'Quantity': '6',
'notes': 'Ready for pickup - ring the bell',
'pickupcontactno': '9876543210',
'expected_pickup_time': now
.add(const Duration(minutes: 12))
.toIso8601String(),
},
{
'orderid': 'MOCK-B-3002',
'pickupid': '3002',
'orderheaderid': '103002',
'orderstatus': 'pending',
'step': 2,
'pickupcustomer': 'Rajesh Bhat',
'pickupaddress': '4, Sunshine Apartments, R.S. Puram',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9419',
'pickuplat': '11.0347',
'pickuplong': '76.9468',
'droplat': '11.0347',
'droplon': '76.9468',
'riderslat': '11.0180',
'riderslon': '76.9580',
'kms': '3.1',
'Quantity': '4',
'notes': 'Parcel - handle with care',
'pickupcontactno': '9876543211',
'expected_pickup_time': now
.add(const Duration(minutes: 24))
.toIso8601String(),
},
{
'orderid': 'MOCK-B-3003',
'pickupid': '3003',
'orderheaderid': '103003',
'orderstatus': 'pending',
'step': 3,
'pickupcustomer': 'Neha Agarwal',
'pickupaddress': '22, Lake View Road, Koundampalayam',
'tenantname': 'Miler Logistics',
'pickuplon': '77.0397',
'pickuplat': '11.0254',
'pickuplong': '77.0102',
'droplat': '11.0254',
'droplon': '77.0102',
'riderslat': '11.0180',
'riderslon': '76.9580',
'kms': '4.6',
'Quantity': '2',
'notes': 'Urgent pickup - handle with care',
'pickupcontactno': '9876543212',
'expected_pickup_time': now
.add(const Duration(minutes: 38))
.toIso8601String(),
},
{
'orderid': 'MOCK-B-3004',
'pickupid': '3004',
'orderheaderid': '103004',
'orderstatus': 'skipped',
'step': 4,
'pickupcustomer': 'Anjali Nair',
'pickupaddress': '10, New Colony, Saravanampatti',
'tenantname': 'Miler Logistics',
'pickuplon': '76.9600',
'pickuplat': '11.0198',
'pickuplong': '76.9512',
'droplat': '11.0198',
'droplon': '76.9512',
'riderslat': '11.0180',
'riderslon': '76.9580',
'kms': '1.8',
'Quantity': '5',
'notes': 'Previously skipped - resume when ready',
'pickupcontactno': '9876543213',
'expected_pickup_time': now
.add(const Duration(minutes: 45))
.toIso8601String(),
},
];
}
List<Map<String, dynamic>> getMockPickups() {
final now = DateTime.now();
return [
{
'orderid': 'MOCK-D-2001',
'pickupid': 'del-mock-d-2001',
'orderstatus': 'picked',
'step': 1,
'pickupcustomer': 'Sarah Johnson',
'pickupaddress': '42 Maple Street, Apt 3B, New York, NY 10001',
'tenantname': 'Miler Logistics',
'pickuplong': -74.0060,
'pickuplat': 40.7580,
'pickuplon': -73.9855,
'kms': '3.2',
'notes': 'Ring doorbell, leave at door',
'expected_pickup_time': now
.add(const Duration(minutes: 25))
.toIso8601String(),
},
{
'orderid': 'MOCK-D-2002',
'pickupid': 'del-mock-d-2002',
'orderstatus': 'picked',
'step': 2,
'pickupcustomer': 'Michael Chen',
'pickupaddress': '158 Broadway, Floor 5, New York, NY 10013',
'tenantname': 'Miler Logistics',
'pickuplong': -74.0020,
'pickuplat': 40.7580,
'pickuplon': -73.9855,
'kms': '1.8',
'notes': 'Call upon arrival, building requires access code #4521',
'expected_pickup_time': now
.add(const Duration(minutes: 40))
.toIso8601String(),
},
{
'orderid': 'MOCK-D-2003',
'pickupid': 'del-mock-d-2003',
'orderstatus': 'picked',
'step': 3,
'pickupcustomer': 'Emily Rodriguez',
'pickupaddress': '275 7th Avenue, Suite 1200, New York, NY 10011',
'tenantname': 'Miler Logistics',
'pickuplong': -73.9950,
'pickuplat': 40.7580,
'pickuplon': -73.9855,
'kms': '2.5',
'notes': 'Pickup to reception desk',
'expected_pickup_time': now
.add(const Duration(minutes: 55))
.toIso8601String(),
},
{
'orderid': 'MOCK-D-2004',
'pickupid': 'del-mock-d-2004',
'orderstatus': 'picked',
'step': 4,
'pickupcustomer': 'James Wilson',
'pickupaddress': '88 University Place, Ground Floor, New York, NY 10003',
'tenantname': 'Miler Logistics',
'pickuplong': -73.9920,
'pickuplat': 40.7580,
'pickuplon': -73.9855,
'kms': '1.2',
'notes': '',
'expected_pickup_time': now
.add(const Duration(minutes: 70))
.toIso8601String(),
},
{
'orderid': 'MOCK-D-2005',
'pickupid': 'del-mock-d-2005',
'orderstatus': 'skipped',
'step': 5,
'pickupcustomer': 'Anna Kim',
'pickupaddress': '301 W 53rd Street, Apt 12A, New York, NY 10019',
'tenantname': 'Miler Logistics',
'pickuplong': -73.9850,
'pickuplat': 40.7580,
'pickuplon': -73.9855,
'kms': '4.1',
'notes': 'Cash payment expected',
'expected_pickup_time': now
.add(const Duration(minutes: 90))
.toIso8601String(),
},
];
}
// ─────────────────────────────────────────────────────────────────────────────
// DEMO TRIPS — three slot windows, every stop type, every decision state.
//
// ── This is a TEST FIXTURE. It does not ship. ──
//
// It used to live in `lib/data/mock_bookings.dart` and fill Home, Bookings and
// the handoff screen whenever the live backend answered with an empty list —
// gated on `kDebugMode`, which sounds safe and is not: the person running the
// debug build is the person deciding whether the feature works, and a screen
// full of convincing invented stops is exactly how a dead endpoint gets signed
// off. The app now shows what the hub actually said, including nothing.
//
// What survives is its second, legitimate job: a rich, deterministic route to
// test the pure `Trip` grouping logic against. Three trips covering every stop
// type in every state is a better fixture than anything a test would build by
// hand, so it moved here rather than being deleted.
// ─────────────────────────────────────────────────────────────────────────────
/// Three trips covering every stop type in every state the rider can see.
///
/// Trip 1 (09:00–12:00) — part-worked. A completed pickup AND a completed
/// delivery, a combined stop in progress, a skipped
/// delivery, and two still awaiting a decision. This
/// is the tab to look at for the locked rows, the
/// amber skip state and the completion ring.
/// Trip 2 (12:00–15:00) — delivery-heavy, all undecided. Multi-parcel drops,
/// a prepaid run, and two combined stops.
/// Trip 3 (15:00–18:00) — combined-heavy, all undecided, and the longest,
/// so select-all and multi-select are worth using.
///
/// **Different data per miler.** This used to return one hard-coded list, so
/// every account — Ramesh, the next tester, the demo rider — opened Home to the
/// exact same six customers, and the same `DM-21xx` order ids. Identical ids
/// across accounts is the worse half: accept/reject is persisted locally by
/// order id, so decisions made while signed in as one miler came back as
/// already-accepted stops for the next one.
///
/// So the *skeleton* is fixed and the *identity* is per rider. Which trip a stop
/// is in, its step, type, status and time window never change — that is what
/// guarantees every UI branch still has something to render, for every miler.
/// The customer, address, coordinates, parcel counts, cash and order ids are
/// drawn from [riderId] / [riderName], deterministically: the same miler gets
/// the same route every launch, two milers never get the same one.
///
/// Called with no rider (signed out) it still yields a valid route — seed 0.
List<Map<String, dynamic>> getDemoTripStops({String? riderName, int? riderId}) {
final now = DateTime.now();
DateTime at(int hour, int minute) =>
DateTime(now.year, now.month, now.day, hour, minute);
final seed = _demoSeed(riderName, riderId);
final rng = _DemoRandom(seed);
// Three digits stamped into every order id, so one miler's demo stops can
// never be mistaken — by the accepted/rejected store or by a human reading
// logs — for another's.
final tag = 100 + (seed % 900);
// Where this miler's route runs. Stepping through the shared pool by a stride
// coprime with its length visits 18 distinct places, and a rider-specific
// start + stride means two milers overlap in few of them.
final start = seed % _demoPlaces.length;
final stride = const [1, 5, 7, 11, 13][seed % 5];
final stops = <Map<String, dynamic>>[];
for (var i = 0; i < _demoSkeleton.length; i++) {
final slot = _demoSkeleton[i];
final place = _demoPlaces[(start + i * stride) % _demoPlaces.length];
// Loads vary per miler, but stay in the range each stop type implies: a
// delivery drops what it carries, a pickup collects, a combined does both.
final int? deliveryQty = slot.type == 'pickup'
? null
: rng.between(1, slot.type == 'delivery' ? 4 : 3);
final int? pickupQty = slot.type == 'delivery'
? null
: rng.between(1, slot.type == 'pickup' ? 8 : 5);
stops.add(
_demoStop(
id: 'DM-$tag${slot.code}',
step: slot.step,
type: slot.type,
status: slot.status,
customer: place.customer,
address: place.address,
lat: place.lat,
lng: place.lng,
qty: pickupQty ?? deliveryQty ?? 1,
deliveryQty: deliveryQty,
pickupQty: pickupQty,
// Round sums to the nearest ten — a COD figure of ₹1247 reads as a bug.
cash: slot.cod ? (rng.between(15, 140) * 10).toDouble() : null,
notes: slot.notes,
due: at(slot.hour, slot.minute),
),
);
}
return stops;
}
/// The Bookings tab's demo route — the same stops Home hands out, in the states
/// a rider who has *already accepted them* would see.
///
/// Bookings was empty on the live backend for two separate reasons, and fixing
/// only one would have left it empty anyway:
///
/// 1. Its mock fallback was gated on `!ApiConfig.useNewApi`, and the new API
/// is the default — the same gate Home and Earnings had.
/// 2. Even with stops injected, the tab drops anything still pending: it
/// shows work the rider has *taken on*, and `getDemoTripStops` returns
/// mostly `status: 'new'`, which that filter removes. So demo stops have
/// to arrive already accepted, or they vanish silently.
///
/// This takes the miler's own route and rewrites the first twelve stops into a
/// believable working day, so the tab has something in every state it can
/// render: a couple finished (green rail), one active (the card that blocks the
/// others), one skipped (amber, awaiting a return), and the rest queued.
List<Map<String, dynamic>> getDemoBookings({String? riderName, int? riderId}) {
final stops = getDemoTripStops(riderName: riderName, riderId: riderId);
if (stops.isEmpty) return stops;
// Trips 1 and 2 — twelve stops is a full screen's worth of scrolling without
// pretending a rider has a forty-stop day.
final slice = stops.take(12).toList();
// Positions chosen so the progress bar shows every colour it has, and the
// active stop is early enough that queued stops sit visibly below it.
const statuses = <int, String>{
0: 'picked', // done
1: 'delivered', // done
2: 'active', // the one he is on
3: 'skipped', // come back to it
};
return [
for (var i = 0; i < slice.length; i++)
<String, dynamic>{
...slice[i],
// Everything else is accepted: taken on, not yet started. That is the
// state the Bookings filter is looking for.
'orderstatus': statuses[i] ?? 'accepted',
},
];
}
/// Stable per-miler seed. FNV-1a over the identity, so neighbouring rider ids
/// (9998, 9999) land far apart instead of drawing near-identical routes.
int _demoSeed(String? riderName, int? riderId) {
final key = (riderId != null && riderId > 0)
? 'id:$riderId'
: 'name:${(riderName ?? '').trim().toLowerCase()}';
if (key == 'name:') return 0;
var h = 2166136261;
for (final c in key.codeUnits) {
h ^= c;
h = (h * 16777619) & 0xFFFFFFFF;
}
return h & 0x7FFFFFFF;
}
/// Deterministic per-miler number source. Not `dart:math`'s Random — this must
/// give the same route on every launch for the same rider, and be seedable from
/// an id rather than the clock.
class _DemoRandom {
int _state;
_DemoRandom(int seed) : _state = (seed | 1) & 0x7FFFFFFF;
int between(int min, int max) {
_state = (_state * 1103515245 + 12345) & 0x7FFFFFFF;
return min + ((_state >> 8) % (max - min + 1));
}
}
/// A place a demo stop can happen at. The pool is shared; which slice of it a
/// miler gets is decided by their seed.
class _DemoPlace {
final String customer;
final String address;
final double lat;
final double lng;
const _DemoPlace(this.customer, this.address, this.lat, this.lng);
}
/// 36 addresses across Coimbatore — twice what one miler's day uses, so two
/// milers' routes look properly different rather than reshuffled.
const List<_DemoPlace> _demoPlaces = [
_DemoPlace(
'Priya Sharma',
'45, Lake View Apartments, Race Course, Coimbatore',
11.0243,
76.9558,
),
_DemoPlace(
'Arun Venkatesh',
'23, Teachers Colony, Saibaba Colony, Coimbatore',
11.0312,
76.9678,
),
_DemoPlace(
'Meena Krishnan',
'5/2, North Avenue, Vadavalli, Coimbatore',
11.0358,
76.9902,
),
_DemoPlace(
'Ravi Subramanian',
'12A, Puliakulam Road, Coimbatore',
11.0021,
76.9721,
),
_DemoPlace(
'Sri Balaji Stores',
'19, Thadagam Road, Venkitapuram, Coimbatore',
11.0289,
76.9312,
),
_DemoPlace(
'Sundar Textiles',
'78, Cross Cut Road, Gandhipuram, Coimbatore',
11.0168,
76.9558,
),
_DemoPlace(
'Kavitha Ramesh',
'12, Bharathi Park Road, Coimbatore',
11.0089,
76.9712,
),
_DemoPlace(
'Hari Prasad',
'7, DB Road, RS Puram, Coimbatore',
11.0043,
76.9498,
),
_DemoPlace(
'Lakshmi Stores',
'61, Trichy Road, Ramanathapuram, Coimbatore',
10.9931,
76.9887,
),
_DemoPlace(
'Anand Electronics',
'3, Oppanakara Street, Town Hall, Coimbatore',
10.9975,
76.9601,
),
_DemoPlace(
'Nandhini Bakery',
'54, 100 Feet Road, Gandhipuram, Coimbatore',
11.0198,
76.9673,
),
_DemoPlace(
'Divya Fashions',
'9, Nava India Road, Peelamedu, Coimbatore',
11.0271,
77.0021,
),
_DemoPlace(
'Ganesh Traders',
'134, Avinashi Road, Hope College, Coimbatore',
11.0301,
77.0142,
),
_DemoPlace(
'Rekha Nair',
'27, Kalapatti Main Road, Coimbatore',
11.0521,
77.0312,
),
_DemoPlace(
'Bright Pharmacy',
'15, Mettupalayam Road, Thudiyalur, Coimbatore',
11.0712,
76.9401,
),
_DemoPlace(
'Selvam Hardware',
'88, Sathy Road, Ganapathy, Coimbatore',
11.0432,
76.9821,
),
_DemoPlace(
'Vetri Apartments',
'4B, Saravanampatti, Coimbatore',
11.0798,
77.0089,
),
_DemoPlace(
'Karthik Rajan',
'31, Vilankurichi Road, Coimbatore',
11.0644,
77.0201,
),
_DemoPlace(
'Muthu Silks',
'22, Raja Street, Town Hall, Coimbatore',
10.9968,
76.9585,
),
_DemoPlace(
'Aishwarya Gopal',
'8, Kamaraj Road, Sowripalayam, Coimbatore',
10.9924,
76.9974,
),
_DemoPlace(
'Velan Provisions',
'41, Ukkadam Main Road, Coimbatore',
10.9861,
76.9563,
),
_DemoPlace(
'Deepa Iyer',
'16, TV Samy Road, RS Puram, Coimbatore',
11.0071,
76.9531,
),
_DemoPlace(
'Kongu Steel Mart',
'102, Mettupalayam Road, Kavundampalayam, Coimbatore',
11.0489,
76.9382,
),
_DemoPlace(
'Sathya Agencies',
'7, Perur Main Road, Kuniamuthur, Coimbatore',
10.9639,
76.9302,
),
_DemoPlace(
'Ramya Sundaram',
'63, Perur Main Road, Kuniamuthur, Marudhamalai Road, Vadavalli, Coimbatore - 634783 ',
11.0301,
76.9151,
),
_DemoPlace(
'Green Leaf Grocers',
'29, Kovaipudur Main Road, Coimbatore',
10.9291,
76.9128,
),
_DemoPlace(
'Prakash Auto Parts',
'55, Sungam Bypass Road, Coimbatore',
10.9832,
76.9752,
),
_DemoPlace(
'Janaki Ammal',
'3, Vivekananda Road, Ram Nagar, Coimbatore',
11.0092,
76.9663,
),
_DemoPlace(
'Coimbatore Book House',
'19, Bashyakarlu Road, RS Puram, Coimbatore',
11.0038,
76.9457,
),
_DemoPlace(
'Tharun Sports',
'74, Tatabad, Gandhipuram, Coimbatore',
11.0212,
76.9631,
),
_DemoPlace(
'Malathi Nursing Home',
'12, Trichy Road, Singanallur, Coimbatore',
10.9948,
77.0298,
),
_DemoPlace(
'Sowmya Devi',
'47, Kurichi Main Road, Coimbatore',
10.9418,
76.9601,
),
_DemoPlace(
'Ashok Timbers',
'91, Palakkad Main Road, Madukkarai, Coimbatore',
10.9061,
76.9327,
),
_DemoPlace(
'Nithya Sweets',
'5, Cheran Managar, Kalapatti, Coimbatore',
11.0562,
77.0221,
),
_DemoPlace(
'Vasanth Motors',
'66, Karumathampatti Road, Neelambur, Coimbatore',
11.0402,
77.1012,
),
_DemoPlace(
'Elango Krishnamoorthy',
'18, Chinnavedampatti Road, Coimbatore',
11.0601,
76.9902,
),
];
/// One position on the day. Everything here is the same for every miler — this
/// is the state-coverage contract the demo data exists to provide.
class _DemoSlot {
/// Trailing digits of the order id: trip number + step. Kept stable so a
/// given miler's stop 3 of trip 2 is always `…2203`.
final String code;
final int step;
/// pickup | delivery | combined
final String type;
/// new | picked | delivered | arrived | skipped
final String status;
final int hour;
final int minute;
/// Whether this stop carries cash. The amount varies per miler; whether the
/// COD branch renders at all does not.
final bool cod;
final String? notes;
const _DemoSlot(
this.code,
this.step,
this.type,
this.hour,
this.minute, {
this.status = 'new',
this.cod = false,
this.notes,
});
}
const List<_DemoSlot> _demoSkeleton = [
// ── Trip 1 · 09:00 – 12:00 · part-worked ────────────────────────────────
_DemoSlot('2101', 1, 'pickup', 9, 20, status: 'picked'), // done
// A finished DELIVERY, not just a finished pickup.
_DemoSlot('2102', 2, 'delivery', 9, 55, status: 'delivered'),
// Active → the locked "In progress" row.
_DemoSlot('2103', 3, 'combined', 10, 30, status: 'arrived', cod: true),
_DemoSlot(
'2104',
4,
'delivery',
10,
50,
status: 'skipped',
notes: 'Customer asked to come back after 11am',
),
_DemoSlot('2105', 5, 'combined', 11, 15),
_DemoSlot(
'2106',
6,
'pickup',
11,
40,
notes: 'Bulk order — ask for the back entrance',
),
// ── Trip 2 · 12:00 – 15:00 · delivery-heavy, all undecided ──────────────
_DemoSlot('2201', 1, 'delivery', 12, 15),
_DemoSlot('2202', 2, 'delivery', 12, 40, notes: 'Buzz flat 3B'),
_DemoSlot('2203', 3, 'combined', 13, 5),
_DemoSlot('2204', 4, 'pickup', 13, 35, cod: true),
// Cash on the pickup leg of a combined stop.
_DemoSlot('2205', 5, 'combined', 14, 0, cod: true),
_DemoSlot('2206', 6, 'delivery', 14, 35),
// ── Trip 3 · 15:00 – 18:00 · combined-heavy, all undecided ──────────────
_DemoSlot('2301', 1, 'combined', 15, 20),
_DemoSlot('2302', 2, 'delivery', 15, 50),
_DemoSlot(
'2303',
3,
'combined',
16,
15,
cod: true,
notes: 'Cold chain box — do not leave in the sun',
),
_DemoSlot(
'2304',
4,
'pickup',
16,
45,
cod: true,
notes: 'Heavy items — bring the large box',
),
_DemoSlot('2305', 5, 'combined', 17, 15),
_DemoSlot('2306', 6, 'delivery', 17, 45),
];
/// Builds one demo stop in the legacy stop shape the UI reads.
Map<String, dynamic> _demoStop({
required String id,
required int step,
required String type,
required String customer,
required String address,
required double lat,
required double lng,
required DateTime due,
String status = 'new',
int qty = 1,
int? deliveryQty,
int? pickupQty,
double? cash,
String? notes,
}) {
return <String, dynamic>{
'orderid': id,
'pickupid': id.replaceAll('DM-', ''),
'orderheaderid': id.replaceAll('DM-', ''),
'pickuplocationid': '1',
'orderstatus': status,
'step': step,
'type': type,
'pickupcustomer': customer,
'tenantname': 'Doormile',
'pickupaddress': address,
'pickupcontactno': '9876543210',
'pickuplat': '$lat',
'pickuplon': '$lng',
'pickuplong': '$lng',
'Quantity': '$qty',
if (deliveryQty != null) 'deliveryqty': '$deliveryQty',
if (pickupQty != null) 'pickupqty': '$pickupQty',
if (cash != null) 'collectionamt': '$cash',
if (notes != null) 'notes': notes,
'weight': '${(qty * 1.4).toStringAsFixed(1)}',
'expected_pickup_time': due.toIso8601String(),
'otp': type == 'pickup' ? '' : '4821',
};
}

152
test/home_appbar_test.dart Normal file
View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Home's app bar carries the rider's identity **and the ON/OFF duty toggle**,
/// and it used to be the first sliver inside the page's `CustomScrollView` — so
/// it scrolled away the moment he looked at his second stop, taking the duty
/// control with it.
///
/// The fix is structural: the bar becomes the first child of a `Column` and the
/// scrollable takes the rest via `Expanded`. `_HomePageState` cannot be built in
/// a test (Get, SharedPreferences, Geolocator, live fetches), so — as in
/// `card_stripe_layout_test.dart` — these pin the *idiom* rather than the widget.
void main() {
const barKey = ValueKey('appbar');
const contentKey = ValueKey('tall-content');
// The default test viewport is 800×600 and the bar is 96 tall, so this point
// is always inside the scroll area below it. Gestures are aimed here rather
// than at the content's centre: the content is 2000pt tall, so its centre is
// off-screen and a drag there hit-tests against nothing.
const inScrollArea = Offset(400, 350);
/// The shape Home now uses.
Widget pinned({Future<void> Function()? onRefresh}) {
final scroller = CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Container(key: contentKey, height: 2000, color: Colors.grey),
),
],
);
return MaterialApp(
home: Scaffold(
body: Stack(
children: [
Column(
children: [
Container(key: barKey, height: 96, color: Colors.white),
Expanded(
child: onRefresh == null
? scroller
: RefreshIndicator(onRefresh: onRefresh, child: scroller),
),
],
),
],
),
),
);
}
/// The shape it used to use — bar as a sliver.
Widget scrolling() {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Container(key: barKey, height: 96, color: Colors.white),
),
SliverToBoxAdapter(
child: Container(key: contentKey, height: 2000, color: Colors.grey),
),
],
),
),
);
}
testWidgets('as a sliver, the bar scrolls off the top', (tester) async {
await tester.pumpWidget(scrolling());
expect(find.byKey(barKey), findsOneWidget);
await tester.dragFrom(inScrollArea, const Offset(0, -300));
await tester.pumpAndSettle();
// Not merely pushed off-screen — a sliver that leaves the viewport and its
// cache extent is unmounted outright, so the duty toggle stopped existing
// rather than just moving. This is the behaviour being fixed.
expect(find.byKey(barKey), findsNothing,
reason: 'this is the behaviour that hid the duty toggle');
});
testWidgets('as a Column child, the bar does not move', (tester) async {
await tester.pumpWidget(pinned());
final before = tester.getRect(find.byKey(barKey));
final contentBefore = tester.getRect(find.byKey(contentKey)).top;
await tester.dragFrom(inScrollArea, const Offset(0, -300));
await tester.pumpAndSettle();
expect(tester.getRect(find.byKey(barKey)), before,
reason: 'the app bar is fixed');
// And the page really did scroll underneath it.
expect(tester.getRect(find.byKey(contentKey)).top,
lessThan(contentBefore - 200));
});
testWidgets('the bar stays put through a long scroll and back',
(tester) async {
await tester.pumpWidget(pinned());
final rect = tester.getRect(find.byKey(barKey));
for (final dy in [-600.0, -600.0, 400.0, -900.0, 1200.0]) {
await tester.dragFrom(inScrollArea, Offset(0, dy));
await tester.pumpAndSettle();
expect(tester.getRect(find.byKey(barKey)), rect);
}
});
testWidgets('pull to refresh still fires below the fixed bar',
(tester) async {
var fired = 0;
await tester.pumpWidget(pinned(onRefresh: () async {
fired++;
await Future<void>.delayed(const Duration(milliseconds: 50));
}));
final rect = tester.getRect(find.byKey(barKey));
await tester.flingFrom(inScrollArea, const Offset(0, 320), 1200);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(fired, 1, reason: 'the gesture still reaches the scroll view');
// The spinner must not drag the bar down with it, which is what the old
// unbounded-overscroll page did to the whole header.
expect(tester.getRect(find.byKey(barKey)), rect);
await tester.pumpAndSettle();
});
testWidgets('the refresh spinner comes down below the bar, not behind it',
(tester) async {
await tester.pumpWidget(pinned(onRefresh: () async {
await Future<void>.delayed(const Duration(milliseconds: 400));
}));
await tester.flingFrom(inScrollArea, const Offset(0, 320), 1200);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
final spinner = find.byType(RefreshProgressIndicator);
expect(spinner, findsOneWidget);
// `edgeOffset` is no longer needed precisely because the scroll viewport now
// starts under the bar — so the indicator is already in clear space.
expect(tester.getRect(spinner).top,
greaterThanOrEqualTo(tester.getRect(find.byKey(barKey)).bottom - 0.01));
await tester.pumpAndSettle();
});
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/app_theme.dart';
import 'package:miler/views/introscreens/introscreen.dart';
/// The first-run slides. The illustrations are miniature mock-ups of the real
/// Home screen rather than a centred Material icon, which means they are made of
/// fixed-width rows — the hub rail on slide 1 is a dozen sized children in a
/// single unflexed `Row`. That is exactly the shape that overflows on a narrow
/// phone, so every slide is laid out at the narrowest screen the app supports.
void main() {
Future<void> pump(WidgetTester tester, {required Size screen}) async {
tester.view.physicalSize = Size(screen.width * 3, screen.height * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
theme: AppTheme.light,
home: const Introscreen(),
),
),
);
await tester.pumpAndSettle();
}
for (final size in const [
Size(320, 640), // smallest Android still in the fleet
Size(360, 740),
Size(390, 844),
Size(430, 932),
]) {
testWidgets('all three slides lay out at ${size.width.toInt()}pt',
(tester) async {
await pump(tester, screen: size);
expect(tester.takeException(), isNull);
// Slide 1 — the slot, its load, and the round trip.
expect(find.text('Your route arrives ready'), findsOneWidget);
expect(find.text('12:00 PM – 3:00 PM'), findsOneWidget);
expect(find.text('Next'), findsOneWidget);
// A near-full-width drag, so the page always snaps to the next one. A
// velocity fling does not reliably cross the threshold here, because each
// slide hosts its own vertical scroll view and the two share the arena.
final swipe = Offset(-size.width * 0.9, 0);
await tester.drag(find.byType(PageView), swipe);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Work the stops in order'), findsOneWidget);
expect(find.text('Skipped'), findsOneWidget);
await tester.drag(find.byType(PageView), swipe);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Collect, then close the loop'), findsOneWidget);
expect(find.text('₹340 cash'), findsOneWidget);
// Last slide swaps the CTA and drops Skip.
expect(find.text('Get started'), findsOneWidget);
expect(find.text('Skip'), findsNothing);
});
}
testWidgets('Skip is offered on the first slide', (tester) async {
await pump(tester, screen: const Size(390, 844));
expect(find.text('Skip'), findsOneWidget);
});
// The old copy pitched a gig marketplace — "pickups near you", accepted from a
// feed. This product assigns routes from a hub. Onboarding that teaches the
// wrong model is worse than none, so the wrong words are pinned out.
testWidgets('does not promise a marketplace the app does not have',
(tester) async {
await pump(tester, screen: const Size(390, 844));
for (final wrong in [
'Find pickups near you',
'Navigate the smart way',
'Earn more, every shift',
'Powered by AI',
]) {
expect(find.text(wrong), findsNothing, reason: '"$wrong" is not this app');
}
});
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/offline/offline_banner.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ── Losing signal must not take the job away ──
///
/// Connectivity used to stack an opaque full-screen page over the entire app.
/// A rider at a doorstep in a basement lost the address, the customer, the
/// parcel count and the OTP field — while holding the parcel. Everything he
/// needs at the door was already fetched; the network is needed to *report* the
/// stop, not to work it.
void main() {
Future<void> pump(WidgetTester tester, {required bool offline}) async {
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: OfflineScope(
offline: offline,
child: Scaffold(
body: Column(
children: const [
Text('45, Lake View Apartments'),
Text('Priya Sharma'),
Text('Delivery OTP'),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('the job stays on screen while offline', (tester) async {
await pump(tester, offline: true);
// The four things a rider cannot do the stop without.
expect(find.text('45, Lake View Apartments'), findsOneWidget);
expect(find.text('Priya Sharma'), findsOneWidget);
expect(find.text('Delivery OTP'), findsOneWidget);
expect(find.byType(OfflineBanner), findsOneWidget);
});
testWidgets('the strip takes its own row — it never covers content', (
tester,
) async {
await pump(tester, offline: true);
// A Column, not a Stack, on purpose: a floating bar over the page would sit
// on top of the OTP box, which is worse than the blocker it replaced.
final strip = tester.getRect(find.byType(OfflineBanner));
final firstContent = tester.getRect(find.text('45, Lake View Apartments'));
expect(strip.bottom, lessThanOrEqualTo(firstContent.top));
});
testWidgets('it is thin enough to cost the job nothing', (tester) async {
await pump(tester, offline: true);
// Roughly one line plus padding. A "banner" that eats 15% of an 844pt
// screen is a blocker with a nicer name.
expect(tester.getSize(find.byType(OfflineBanner)).height, lessThan(64));
});
testWidgets('it says what happens next, not just what is wrong', (
tester,
) async {
await pump(tester, offline: true);
final text = tester
.widgetList<Text>(
find.descendant(
of: find.byType(OfflineBanner),
matching: find.byType(Text),
),
)
.first
.data!;
expect(text, contains('Offline'));
expect(text.toLowerCase(), contains('sync'));
});
testWidgets('online, it occupies no space at all', (tester) async {
await pump(tester, offline: false);
expect(find.byType(OfflineBanner), findsNothing);
expect(tester.getRect(find.text('45, Lake View Apartments')).top, lessThan(20));
});
testWidgets('the strip carries a readable label on its fill', (tester) async {
await pump(tester, offline: true);
final material = tester.widget<Material>(
find
.descendant(
of: find.byType(OfflineBanner),
matching: find.byType(Material),
)
.first,
);
// `warning`, which the contrast contract holds to 4.5:1 behind white.
expect(material.color, ColorConstants.warning);
});
}

View File

@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/profile/informations/faq.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// The pages you can navigate *into* from Account.
///
/// ── Why these needed a test ──
///
/// Eight pushed pages had six different app bars between them: four solid brand
/// red with white text, two white with black text, one grey, one white with a
/// red title. The back arrow came in four different icons at four sizes in three
/// colours. Two shared helpers already existed and, between them, were used by
/// three pages.
///
/// And the FAQ page **overflowed at any text scale at or above 1.5** — three
/// separate places, on every screen width — which is a black-and-yellow band
/// across a help page for the riders most likely to need help reading it.
///
/// Both classes of bug are invisible in a screenshot taken at default settings
/// on the developer's own phone, so both are measured here instead.
void main() {
Future<void> pump(
WidgetTester tester,
Widget page, {
double width = 390,
double textScale = 1.0,
}) async {
tester.view.physicalSize = Size(width * 3, 800 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context)
.copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: page,
),
),
);
await tester.pumpAndSettle();
}
group('the shared pushed-page bar', () {
testWidgets('is a glass surface with a slate title', (tester) async {
await pump(
tester,
Scaffold(appBar: milerPageBar('Saved Addresses', onBack: () {})),
);
// The bar is transparent and wears `milerGlassSurface` — a frosted 7%
// brand wash — rather than a solid slab of either white or red. The rule
// it is holding to is unchanged: the surface stays light enough for
// near-black type, so the loudest colour in the palette is not spent on
// the one element that never changes.
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.backgroundColor, Colors.transparent);
expect(bar.flexibleSpace, isNotNull,
reason: 'the glass wash is what colours the bar now');
// ── Slate, not maroon ──
//
// This was the only heading in the app in the brand colour, so an Account
// page's title read as a link while the same heading on a tab was
// near-black. One title colour across every bar; the brand still owns the
// glass wash behind it.
final title = tester.widget<Text>(find.text('Saved Addresses'));
expect(title.style!.color, ColorConstants.slateText);
expect(title.style!.fontSize, MilerType.pageTitleFixed.fontSize,
reason: 'one page-title size across tabs and pushed screens');
});
testWidgets('carries a back affordance when given one', (tester) async {
var popped = false;
await pump(
tester,
Scaffold(appBar: milerPageBar('Notifications', onBack: () => popped = true)),
);
// ── An arrow, because these pages are pushed ──
//
// The cross promised dismissal on nine screens you actually go *back*
// from, and it was one of three back glyphs in the app. `close_rounded`
// survives behind `isModal: true`, for a sheet that genuinely dismisses.
expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget);
expect(find.byIcon(Icons.close_rounded), findsNothing);
await tester.tap(find.byIcon(Icons.arrow_back_rounded));
await tester.pumpAndSettle();
expect(popped, isTrue);
});
testWidgets('and none when it is not', (tester) async {
await pump(tester, Scaffold(appBar: milerPageBar('Terms')));
expect(find.byIcon(Icons.arrow_back_rounded), findsNothing);
expect(find.byIcon(Icons.close_rounded), findsNothing);
});
testWidgets('a modal presentation still gets a cross', (tester) async {
await pump(
tester,
Scaffold(
appBar: milerPageBar('Skip stop', onBack: () {}, isModal: true),
),
);
expect(find.byIcon(Icons.close_rounded), findsOneWidget);
expect(find.byIcon(Icons.arrow_back_rounded), findsNothing);
});
testWidgets('survives a long title and a large font', (tester) async {
await pump(
tester,
Scaffold(
appBar: milerPageBar(
'A Very Long Page Title That Will Not Fit',
onBack: () {},
),
),
width: 320,
textScale: 2.0,
);
expect(tester.takeException(), isNull);
});
// ── One line, not two ──
//
// The bar used to carry a caption under the title. It was the smallest type
// on the screen in the one place a rider reads exactly once, and every
// caption duplicated something the page said louder further down — two
// sources for one number, which is how they drift apart.
testWidgets('carries the page name and nothing under it', (tester) async {
await pump(tester, Scaffold(appBar: milerPageBar('Notifications')));
final texts = find.byType(Text);
expect(tester.widgetList<Text>(texts), hasLength(1));
expect(find.text('Notifications'), findsOneWidget);
});
// ── The × is stacked ABOVE the heading, on the same margin ──
//
// It used to share the row: `[×] Edit Profile`. That pushed every title on
// every pushed page 52pt in from the margin, out of line with the content
// beneath it, and a control sitting at a title's left at the same optical
// weight reads as part of the title rather than as something you press.
testWidgets('stacks the close above the title', (tester) async {
await pump(
tester,
Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})),
);
final close = tester.getRect(find.byIcon(Icons.arrow_back_rounded));
final title = tester.getRect(find.text('Edit Profile'));
expect(
close.bottom,
lessThanOrEqualTo(title.top),
reason: 'the back control must sit on its own row above the heading',
);
expect(
close.left,
closeTo(title.left, 0.5),
reason:
'they share the page margin — an IconButton could not do this, '
'which is why the close is hand-rolled. See _TopBarClose.',
);
});
testWidgets('gives the back control a full 48pt target', (tester) async {
await pump(
tester,
Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})),
);
final box = tester.getSize(
find.ancestor(
of: find.byIcon(Icons.arrow_back_rounded),
matching: find.byType(SizedBox),
).first,
);
// 48, not the 44pt accessibility floor: this is the control a rider hits
// one-handed, and the glyph inside stays 24 so only the target grew.
expect(box.width, 48);
expect(box.height, 48);
});
});
group('FAQ lays out at every font scale', () {
// It did not. `Container(height: 160.h)` around the hero overflowed by 89px
// at 1.5x; the category grid's `childAspectRatio` derived cell height from
// the column *width*, so all four tiles overflowed by ~10px; and the SOS
// row's two unflexed children ran 30px past the card.
for (final width in [320.0, 360.0, 390.0, 430.0]) {
for (final scale in [1.0, 1.3, 1.5, 2.0]) {
testWidgets('at ${width.toInt()}pt / ${scale}x', (tester) async {
await pump(tester, const FaqPage(),
width: width, textScale: scale);
expect(tester.takeException(), isNull,
reason: 'FAQ overflowed at ${width.toInt()}pt / ${scale}x');
// Opening the accordions is where the page grows the most.
final tiles = find.byType(ExpansionTile);
for (var i = 0; i < tiles.evaluate().length; i++) {
await tester.tap(tiles.at(i), warnIfMissed: false);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull,
reason: 'FAQ overflowed opening accordion $i at '
'${width.toInt()}pt / ${scale}x');
}
});
}
}
});
// Terms & Conditions is deliberately not laid out here: it hosts a
// `WebView`, and `WebViewPlatform.instance` has no implementation in a headless
// unit test, so the page cannot be built at all. Its app bar is the shared one
// — that much is covered by the group above — but the body needs a device or
// an integration test.
}

102
test/review_grid_test.dart Normal file
View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
/// The review step is the last screen before a stop is closed, and the only
/// thing the rider does on it is check figures. It used to spend a full line on
/// each — an icon, a caption and a value — for values that are almost always
/// four characters, so six facts ran past the fold and he had to scroll a
/// *confirmation* screen to see what he was confirming.
///
/// The packing rule is what fixed that, and it is asserted here rather than on
/// screen: the review sits behind a camera gate no widget test can pass.
void main() {
ReviewFact narrow(String label, String value) =>
ReviewFact(label, value, icon: Icons.circle);
ReviewFact wide(String label, String value) =>
ReviewFact(label, value, icon: Icons.circle, wide: true);
List<List<String>> labels(List<List<ReviewFact>> rows) =>
[for (final r in rows) [for (final f in r) f.label]];
test('two short facts share a row', () {
final rows = packReviewFacts([
narrow('Parcels', '3 of 3'),
narrow('Weight', '4.5 kg'),
]);
expect(labels(rows), [
['Parcels', 'Weight'],
]);
});
test('a sentence takes its own row', () {
// Wrapping a recipient's name or a damage note into half a column is how a
// grid turns back into a wall of text.
final rows = packReviewFacts([
narrow('Parcels', '3 of 3'),
narrow('Weight', '4.5 kg'),
wide('Note', 'Corner of the second box is crushed'),
]);
expect(labels(rows), [
['Parcels', 'Weight'],
['Note'],
]);
});
test('a wide fact does not drag its neighbour onto its row', () {
final rows = packReviewFacts([
wide('Received by', 'Ramesh Kumar · Security'),
narrow('OTP', '482156'),
]);
expect(labels(rows), [
['Received by'],
['OTP'],
]);
});
test('empty values are dropped, not rendered as dashes', () {
final rows = packReviewFacts([
narrow('Parcels', '3 of 3'),
narrow('Pickup code', ''),
narrow('Weight', '4.5 kg'),
]);
expect(labels(rows), [
['Parcels', 'Weight'],
]);
});
test('order is meaning, so nothing is re-sorted to pack tighter', () {
final rows = packReviewFacts([
narrow('Parcels', '3 of 3'),
wide('Note', 'Shutter was half down'),
narrow('Weight', '4.5 kg'),
narrow('Condition', 'Sealed & intact'),
]);
// A tighter packing exists — pair Parcels with Weight and push the note
// down — and it is deliberately not taken.
expect(labels(rows), [
['Parcels'],
['Note'],
['Weight', 'Condition'],
]);
});
test('the collect leg fits in three rows instead of six', () {
final rows = packReviewFacts([
narrow('Parcels', '3 of 3'),
narrow('Weight', '4.5 kg'),
narrow('Condition', 'Sealed & intact'),
narrow('Pickup code', '482156'),
wide('Note', 'Customer only had two ready'),
]);
expect(rows, hasLength(3));
});
}

72
test/rider_tier_test.dart Normal file
View File

@@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/rider_tier.dart';
/// The ladder behind the Rewards page. Modelled on Uber Pro: points earned over
/// a fixed period decide the status, and the screen's job is to answer "how far
/// is the next one" in points a rider can act on today.
void main() {
group('standing', () {
test('starts at the bottom rung', () {
final s = TierStanding.of(0);
expect(s.tier, RiderTier.blue);
expect(s.nextTier, RiderTier.silver);
expect(s.pointsToNext, kTierThresholds[RiderTier.silver]);
expect(s.progressToNext, 0);
});
test('lands on the highest threshold passed, not the nearest', () {
expect(TierStanding.of(149).tier, RiderTier.blue);
expect(TierStanding.of(150).tier, RiderTier.silver);
expect(TierStanding.of(399).tier, RiderTier.silver);
expect(TierStanding.of(400).tier, RiderTier.gold);
});
// A rider one point into Gold should see an empty bar to Platinum, not a
// bar already half full because his lifetime number is big.
test('progress is measured across the current band', () {
final justIn = TierStanding.of(400);
expect(justIn.tier, RiderTier.gold);
expect(justIn.progressToNext, 0);
final halfway = TierStanding.of(600); // 400 → 800
expect(halfway.progressToNext, closeTo(0.5, 0.001));
});
test('the top of the ladder has nothing left to chase', () {
final s = TierStanding.of(5000);
expect(s.tier, RiderTier.platinum);
expect(s.nextTier, isNull);
expect(s.pointsToNext, isNull);
expect(s.progressToNext, 1);
});
test('every tier knows the one above it, and the last one does not', () {
expect(RiderTier.blue.next, RiderTier.silver);
expect(RiderTier.gold.next, RiderTier.platinum);
expect(RiderTier.platinum.next, isNull);
});
});
group('reward period', () {
test('is the calendar month, whole and inclusive', () {
final p = RewardPeriod.current(DateTime(2026, 8, 5));
expect(p.start, DateTime(2026, 8, 1));
expect(p.end, DateTime(2026, 8, 31));
expect(p.label, 'August 2026');
});
test('counts today as a day left, and never goes negative', () {
final p = RewardPeriod.current(DateTime(2026, 8, 30));
expect(p.daysLeft(DateTime(2026, 8, 30)), 2);
expect(p.daysLeft(DateTime(2026, 8, 31)), 1);
expect(p.daysLeft(DateTime(2026, 9, 4)), 0);
});
test('handles a December period rolling into the new year', () {
final p = RewardPeriod.current(DateTime(2026, 12, 10));
expect(p.end, DateTime(2026, 12, 31));
expect(p.label, 'December 2026');
});
});
}

313
test/route_brief_test.dart Normal file
View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/route_brief.dart';
void main() {
// Two stops roughly 2.5 km and 3.5 km from a Coimbatore origin.
final stops = <String, Map<String, dynamic>>{
'A': {
'Quantity': '3',
'weight': '4.5',
'collectionamt': '250',
'pickuplat': '11.0243',
'pickuplon': '76.9558',
'kms': '2.1',
},
'B': {
'Quantity': '8',
'weight': '2',
'collectionamt': '0',
'pickuplat': '11.0312',
'pickuplon': '76.9678',
'kms': '1.5',
},
};
group('RouteBrief load & capacity', () {
test('sums parcels, weight and cash across the route', () {
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
);
expect(brief.stops, 2);
expect(brief.parcels, 11);
expect(brief.weightKg, 6.5);
expect(brief.collectAmount, 250);
});
test('counts accepted stops that already left the pending list', () {
final brief = RouteBrief.from(
orderIds: const ['A'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
extraStops: [stops['B']!],
);
// Same totals as if both were still pending — an accepted stop is still
// a stop the rider has to ride to.
expect(brief.stops, 2);
expect(brief.parcels, 11);
expect(brief.weightKg, 6.5);
});
test('strips units baked into numeric strings', () {
final brief = RouteBrief.from(
orderIds: const ['A'],
ordersMap: {
'A': {'Quantity': '2', 'weight': '3.25 kg', 'collectionamt': '₹180'},
},
shiftStart: '',
shiftEnd: '',
);
expect(brief.weightKg, 3.25);
expect(brief.collectAmount, 180);
});
});
group('RouteBrief round-trip distance', () {
test('closes the loop back to the origin', () {
final withOrigin = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
riderLat: 11.0180,
riderLng: 76.9580,
);
final withoutOrigin = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
);
// Origin → A → B → origin must be longer than the bare A → B leg.
expect(withOrigin.routeKm, greaterThan(withoutOrigin.routeKm));
expect(withOrigin.routeKm, greaterThan(4));
expect(withOrigin.routeKm, lessThan(15));
});
test('falls back to the per-stop kms field when coordinates are missing',
() {
final brief = RouteBrief.from(
orderIds: const ['A'],
ordersMap: {
'A': {'Quantity': '1', 'kms': '4.2'},
},
shiftStart: '',
shiftEnd: '',
);
expect(brief.routeKm, 4.2);
});
});
group('RouteBrief shift window', () {
test('flags a missing shift', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '',
shiftEnd: '',
);
expect(brief.shiftSet, isFalse);
expect(brief.timeLeft, isNull);
expect(brief.shiftWindowLabel, 'Shift not set');
});
test('flags a half-set shift as not set', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '09:00:00',
shiftEnd: '',
);
expect(brief.shiftSet, isFalse);
});
test('computes time left and a per-stop budget', () {
final now = DateTime(2026, 7, 27, 15, 0);
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: now,
);
expect(brief.shiftSet, isTrue);
expect(brief.timeLeft, const Duration(hours: 3));
expect(brief.perStopBudget, const Duration(minutes: 90));
expect(brief.isPaceAtRisk, isFalse);
expect(brief.shiftWindowLabel, '9:00 AM – 6:00 PM');
});
test('clamps a finished shift to zero instead of going negative', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: DateTime(2026, 7, 27, 20, 0),
);
expect(brief.timeLeft, Duration.zero);
expect(brief.perStopBudget, isNull);
});
test('rolls an overnight shift end into the next day', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '22:00:00',
shiftEnd: '06:00:00',
now: DateTime(2026, 7, 27, 23, 0),
);
expect(brief.timeLeft, const Duration(hours: 7));
});
test('raises the pace flag when stops cannot fit in the time left', () {
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: DateTime(2026, 7, 27, 17, 51), // 9 minutes for 2 stops
);
expect(brief.isPaceAtRisk, isTrue);
});
test('parses 12-hour shift strings', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '9:30 AM',
shiftEnd: '6:00 PM',
now: DateTime(2026, 7, 27, 12, 0),
);
expect(brief.shiftSet, isTrue);
expect(brief.timeLeft, const Duration(hours: 6));
});
});
/// A missing shift is somebody else's admin: the rider cannot fix it, did not
/// cause it, and is not blocked by it — the trips still load and he can still
/// ride them. So the banner says nothing at all rather than parking a
/// permanent amber "No shift set for today" box at the top of his first
/// screen. These tests keep it that way, because a box like that comes back
/// easily and always with good intentions.
group('ShiftBanner', () {
Future<void> pump(WidgetTester tester, RouteBrief brief) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(body: ShiftBanner(brief: brief)),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('renders nothing at all when no shift is set', (tester) async {
await pump(
tester,
RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
),
);
expect(tester.takeException(), isNull);
expect(find.text('No shift set for today'), findsNothing);
expect(find.text('Retry'), findsNothing);
expect(find.textContaining('hub manager'), findsNothing);
// Not merely empty of those strings — the widget takes no height, so the
// route below it starts at the top of the page.
expect(tester.getSize(find.byType(ShiftBanner)).height, 0);
});
testWidgets('still shows the window once the hub sets one', (tester) async {
await pump(
tester,
RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: DateTime(2026, 7, 27, 15, 0),
),
);
expect(tester.takeException(), isNull);
expect(find.text('3h left'), findsOneWidget);
expect(tester.getSize(find.byType(ShiftBanner)).height, greaterThan(0));
});
});
group('RouteBrief time windows', () {
final now = DateTime(2026, 7, 27, 15, 0);
test('counts stops past their slot and stops closing soon', () {
final brief = RouteBrief.from(
orderIds: const ['late', 'soon', 'later'],
ordersMap: {
'late': {
'expected_pickup_time':
now.subtract(const Duration(minutes: 20)).toIso8601String(),
},
'soon': {
'expected_pickup_time':
now.add(const Duration(minutes: 10)).toIso8601String(),
},
'later': {
'expected_pickup_time':
now.add(const Duration(hours: 3)).toIso8601String(),
},
},
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: now,
);
expect(brief.overdueStops, 1);
expect(brief.dueSoonStops, 1);
});
test('ignores stops with no slot time', () {
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: now,
);
expect(brief.overdueStops, 0);
expect(brief.dueSoonStops, 0);
});
});
group('formatDuration', () {
test('reads like a clock', () {
expect(formatDuration(const Duration(minutes: 45)), '45m');
expect(formatDuration(const Duration(hours: 2, minutes: 15)), '2h 15m');
expect(formatDuration(const Duration(hours: 3)), '3h');
expect(formatDuration(const Duration(seconds: 20)), 'under a minute');
});
});
}

161
test/route_flow_test.dart Normal file
View File

@@ -0,0 +1,161 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/pickups/pickups.dart';
/// The route flow on the stop map used to be a comet: a window covering 18% of
/// the route, sliding from one end to the other. At any moment the rider saw a
/// short white dash somewhere in the middle of his journey, which reads as a
/// decoration on the map rather than as a statement about his trip — and on a
/// long route the dash never visibly connected the two ends at all.
///
/// It is now one continuous stream: white grows from the rider's own position to
/// the stop, holds, then drains back down the line to him, forever. The map, the
/// ticker and the live route make that untestable as a widget — but the claims
/// are claims about numbers, and these are the numbers.
void main() {
// Representative shares: a ~2.4s grow, a beat, a ~0.95s drain.
const grow = 0.66;
const hold = 0.07;
({RouteFlowPhase phase, double progress}) at(double t) =>
routeFlowFrame(t, growShare: grow, holdShare: hold);
group('the cycle', () {
test('starts at the rider, with nothing drawn ahead of him', () {
final f = at(0);
expect(f.phase, RouteFlowPhase.grow);
// Head at 0 of the route's length: the stream begins exactly at the
// rider's own position, not somewhere along the line.
expect(f.progress, 0);
});
test('grows at a constant rate all the way to the stop', () {
// Linear, not eased. The draw-in is eased because it is an entrance; this
// is travel, and an eased stream appears to slow down in the middle of the
// road for no reason.
double head(double t) => at(t).progress;
final quarter = head(grow * 0.25);
final half = head(grow * 0.5);
final threeQuarters = head(grow * 0.75);
expect(quarter, closeTo(0.25, 1e-9));
expect(half, closeTo(0.5, 1e-9));
expect(threeQuarters, closeTo(0.75, 1e-9));
// Equal time, equal distance — at every point in the phase.
expect(half - quarter, closeTo(threeQuarters - half, 1e-9));
});
test('reaches the destination exactly at the end of the grow', () {
expect(at(grow).phase, RouteFlowPhase.grow);
expect(at(grow).progress, closeTo(1.0, 1e-9));
});
test('holds at full length before draining', () {
final f = at(grow + hold / 2);
expect(f.phase, RouteFlowPhase.hold);
});
test('drains for the rest of the cycle, and is finished at the end', () {
expect(at(grow + hold + 0.001).phase, RouteFlowPhase.fade);
final end = at(1);
expect(end.phase, RouteFlowPhase.fade);
expect(end.progress, closeTo(1.0, 1e-9));
});
test('the phases tile the cycle with no gap and no overlap', () {
// A gap is a frame with nothing drawn; an overlap is a frame drawn twice.
// Either shows up as a stutter at the seam.
RouteFlowPhase? previous;
final seen = <RouteFlowPhase>[];
for (var i = 0; i <= 1000; i++) {
final phase = at(i / 1000).phase;
if (phase != previous) seen.add(phase);
previous = phase;
}
expect(seen, [
RouteFlowPhase.grow,
RouteFlowPhase.hold,
RouteFlowPhase.fade,
]);
});
});
group('the drain', () {
test('the line is whole the instant the fade begins', () {
// Not already clipped at the far end — which is what made the old loop
// appear to restart before it had arrived.
for (final x in kRouteFlowStops) {
expect(routeFlowFadeAlpha(0, x), closeTo(1.0, 1e-9));
}
});
test('nothing is left when the fade ends', () {
for (final x in kRouteFlowStops) {
expect(routeFlowFadeAlpha(1, x), 0);
}
});
test('it drains from the destination back towards the rider', () {
// At any partial moment the stop end is at most as bright as the rider
// end: the front travels backwards down the route, it does not dissolve
// the whole line at once.
for (var i = 1; i < 10; i++) {
final f = i / 10;
final atRider = routeFlowFadeAlpha(f, 0);
final atStop = routeFlowFadeAlpha(f, 1);
expect(
atStop,
lessThanOrEqualTo(atRider + 1e-9),
reason: 'at f=$f the stop end should lead the fade',
);
}
// And the rider's end is the last thing still lit.
expect(routeFlowFadeAlpha(0.6, 1), 0);
expect(routeFlowFadeAlpha(0.6, 0), greaterThan(0));
});
test('it is soft, not a wipe', () {
// Mid-drain there is a ramp rather than a hard edge: at least one stop
// sits strictly between lit and dark, which is the whole difference
// between a fade and a shutter.
final partial = [
for (final x in kRouteFlowStops) routeFlowFadeAlpha(0.45, x),
];
expect(partial.any((a) => a > 0.01 && a < 0.99), isTrue);
});
test('every point only ever gets dimmer', () {
for (final x in kRouteFlowStops) {
var previous = double.infinity;
for (var i = 0; i <= 100; i++) {
final a = routeFlowFadeAlpha(i / 100, x);
expect(a, lessThanOrEqualTo(previous + 1e-9));
previous = a;
}
}
});
});
group('degenerate shares', () {
test('a cycle with no hold still tiles', () {
final f = routeFlowFrame(0.9, growShare: 0.8, holdShare: 0);
expect(f.phase, RouteFlowPhase.fade);
expect(f.progress, closeTo(0.5, 1e-9));
});
test('a cycle with no grow starts already arrived', () {
final f = routeFlowFrame(0, growShare: 0, holdShare: 0.1);
expect(f.phase, RouteFlowPhase.hold);
});
test('t outside 0..1 is clamped rather than extrapolated', () {
expect(routeFlowFrame(-1, growShare: grow, holdShare: hold).progress, 0);
expect(routeFlowFrame(2, growShare: grow, holdShare: hold).phase,
RouteFlowPhase.fade);
expect(routeFlowFadeAlpha(-1, 0.5), 1);
expect(routeFlowFadeAlpha(9, 0.5), 0);
});
});
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/selection_bar.dart';
/// The bar is the only place a decision is committed now that stop rows are
/// tick-only, so it has to carry BOTH decisions — and carry them at every
/// width, since the labels sit beside a live count.
void main() {
Future<void> pump(
WidgetTester tester, {
required int count,
required double width,
bool withReject = true,
}) async {
tester.view.physicalSize = Size(width * 3, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: SelectionBar(
count: count,
onAccept: () {},
onReject: withReject ? () {} : null,
onClear: () {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('offers both decisions on the ticked set', (tester) async {
await pump(tester, count: 3, width: 390);
expect(find.text('Accept 3'), findsOneWidget);
expect(find.text('Reject'), findsOneWidget);
});
testWidgets('reject fires with the selection', (tester) async {
var rejected = 0;
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: SelectionBar(
count: 2,
onAccept: () {},
onReject: () => rejected++,
onClear: () {},
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Reject'));
await tester.pumpAndSettle();
expect(rejected, 1);
});
testWidgets('hides reject when the page does not offer it', (tester) async {
await pump(tester, count: 2, width: 390, withReject: false);
expect(find.text('Reject'), findsNothing);
expect(find.text('Accept 2'), findsOneWidget);
});
// Two buttons and a count on one line is exactly the shape that overflows on
// a small phone, and an overflowing action bar is the one place the rider
// cannot work around it.
for (final width in [320.0, 360.0, 390.0, 430.0]) {
testWidgets('lays out at ${width.toInt()}pt', (tester) async {
await pump(tester, count: 12, width: width);
expect(tester.takeException(), isNull);
});
}
}

View File

@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/accepted_store.dart';
/// A skip is now shown on Activity rather than Home, and Activity reads its own
/// endpoints — which do not report a skip for a poll or two, and never on demo
/// data. So the store is what makes a skipped stop visible at all, and these
/// are the properties the two screens rely on:
///
/// • it survives the app being killed mid-shift;
/// • it stamps the stop so one `stopStatusOf` test recognises it everywhere;
/// • it forgets a resumed stop, or Activity would keep offering Resume for
/// something already back on Bookings;
/// • it forgets yesterday, or the tab grows without bound.
void main() {
Map<String, dynamic> booking(String id) => <String, dynamic>{
'orderid': id,
'pickupid': '9$id',
'pickupcustomer': 'Sri Balaji Stores',
'orderstatus': 'active',
};
setUp(() => SharedPreferences.setMockInitialValues({}));
test('a skip is recorded with its reason and reads back as skipped',
() async {
await addSkippedBooking(booking('A'), reason: 'Customer unreachable');
final stored = await getSkippedBookings();
expect(stored, hasLength(1));
expect(stored.single['skipreason'], 'Customer unreachable');
expect(
stopStatusOf(stored.single),
StopStatus.skipped,
reason: 'the stamp is what lets one status test serve every screen',
);
expect(await getSkippedOrderIds(), {'A'});
});
test('the same stop skipped twice is one row, not two', () async {
await addSkippedBooking(booking('A'), reason: 'Customer unreachable');
await addSkippedBooking(booking('A'), reason: 'Shutter closed');
final stored = await getSkippedBookings();
expect(stored, hasLength(1));
expect(stored.single['skipreason'], 'Shutter closed');
});
test('resuming forgets the skip', () async {
await addSkippedBooking(booking('A'), reason: 'Customer unreachable');
await addSkippedBooking(booking('B'), reason: 'Not at the location');
await removeSkippedBookings(['A']);
expect(await getSkippedOrderIds(), {'B'});
});
test('yesterday is dropped on read', () async {
// Written the way the store writes, but stamped for another day.
final stale = booking('A')
..['orderstatus'] = 'skipped'
..['skipreason'] = 'Customer unreachable'
..['skippedat'] = '2020-01-01T09:00:00.000'
..['skippedday'] = '2020-01-01';
SharedPreferences.setMockInitialValues({
'skipped_bookings': jsonEncode([stale]),
});
expect(await getSkippedBookings(), isEmpty);
// And the prune is persisted, so the row cannot come back.
final prefs = await SharedPreferences.getInstance();
expect(jsonDecode(prefs.getString('skipped_bookings')!), isEmpty);
});
test('newest first — the stop he just put down leads the list', () async {
await addSkippedBooking(booking('A'), reason: 'Customer unreachable');
await Future<void>.delayed(const Duration(milliseconds: 5));
await addSkippedBooking(booking('B'), reason: 'Shutter closed');
final stored = await getSkippedBookings();
expect(stored.map((s) => s['orderid']), ['B', 'A']);
});
}

View File

@@ -0,0 +1,297 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ── Stop type may be a mark, never a surface ──
///
/// Pending stop cards used to be *filled* with their stop-type accent at 5% and
/// bordered with the same colour at 38%, so the route came down the screen as a
/// lavender pane, then a pink one, then a blue one.
///
/// The line drawn now is between a mark and a surface. A small filled chip
/// naming the mode is a mark: it is a tag, it is bounded, and it does not colour
/// anything the rider is reading through. A card fill is a surface, and tinting
/// it puts a wash behind the name, the address and the buttons — which is where
/// Uber's Base rule bites: surfaces are white or one neutral grey, and the
/// accent is reserved for "one moment per screen".
///
/// So the type colours are allowed to appear, on a chip, and the chip fills use
/// the *darkened* variants ([ColorConstants.pickupChip] and friends) so white
/// text on them clears AA. What they may not do is fill or border a card.
///
/// These tests pin that by colour value, which is the only way it stays pinned —
/// a comment saying "keep this neutral" does not survive a redesign.
///
/// Note on coverage: `pickupAccent` is an alias of `ColorConstants.primary`, so
/// it is indistinguishable from the legitimate brand accent and cannot be
/// asserted against.
void main() {
Map<String, dynamic> stop(
String id, {
required String type,
double cash = 0,
int deliverQty = 0,
int pickupQty = 0,
}) =>
{
'orderid': id,
'type': type,
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore',
'pickupcontactno': '9876543210',
if (deliverQty > 0) 'deliveryqty': '$deliverQty',
if (pickupQty > 0) 'pickupqty': '$pickupQty',
'Quantity': '3',
if (cash > 0) 'collectionamt': '$cash',
'expected_pickup_time': DateTime.now().toIso8601String(),
};
Future<void> pumpCard(WidgetTester tester,
{Set<String> accepted = const {}, double width = 390}) async {
tester.view.physicalSize = Size(width * 3, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final trip = Trip.fromStops(
id: 'mixed',
stops: [
stop('P', type: 'pickup', pickupQty: 4),
stop('D', type: 'delivery', deliverQty: 2),
stop('C', type: 'combined', deliverQty: 1, pickupQty: 2, cash: 340),
],
);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: trip,
acceptedIds: accepted,
onToggleSelect: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
/// Every colour the card paints: decoration fills, border sides, icon tints
/// and text colours (including rich-text spans).
List<(String, Color)> paintedColours(WidgetTester tester) {
final found = <(String, Color)>[];
void addBorder(String where, BoxBorder? b) {
if (b is Border) {
for (final side in [b.top, b.right, b.bottom, b.left]) {
if (side.style != BorderStyle.none) found.add(('$where.border', side.color));
}
}
}
// Walks `children` directly rather than via `visitChildren`: that visitor
// yields the span itself before its children, so recursing from inside it
// re-enters on the same node forever.
void addSpan(String where, InlineSpan? span) {
if (span == null) return;
final c = span.style?.color;
if (c != null) found.add(('$where.span', c));
if (span is TextSpan) {
for (final child in span.children ?? const <InlineSpan>[]) {
addSpan(where, child);
}
}
}
for (final w in tester.allWidgets) {
switch (w) {
case Container(:final decoration):
if (decoration is BoxDecoration) {
if (decoration.color != null) {
found.add(('Container.color', decoration.color!));
}
addBorder('Container', decoration.border);
}
case DecoratedBox(:final decoration):
if (decoration is BoxDecoration) {
if (decoration.color != null) {
found.add(('DecoratedBox.color', decoration.color!));
}
addBorder('DecoratedBox', decoration.border);
}
case Icon(:final color) when color != null:
found.add(('Icon', color));
case Text(:final style, :final textSpan):
if (style?.color != null) found.add(('Text', style!.color!));
addSpan('Text', textSpan);
case RichText(:final text):
addSpan('RichText', text);
default:
break;
}
}
return found;
}
/// The *undarkened* accents. These are the values that were washing the card
/// fills and borders; the chip uses the darkened variants instead, so seeing
/// one of these anywhere means a tint has crept back.
final bannedOutright = <String, Color>{
'deliveryAccent (royal blue)': ColorConstants.deliveryAccent,
'combinedAccent (deep indigo)': ColorConstants.combinedAccent,
};
testWidgets('the un-darkened type accents are not painted at all',
(tester) async {
await pumpCard(tester);
final painted = paintedColours(tester);
expect(painted, isNotEmpty, reason: 'sanity: the card painted something');
for (final entry in bannedOutright.entries) {
final hits = painted.where((p) => p.$2 == entry.value).toList();
expect(hits, isEmpty,
reason: '${entry.key} is the light accent — the chip should use the '
'darkened variant. Found at ${hits.map((h) => h.$1).toSet()}');
}
});
testWidgets('the same holds once some stops are accepted', (tester) async {
await pumpCard(tester, accepted: {'D', 'C'});
final painted = paintedColours(tester);
for (final entry in bannedOutright.entries) {
expect(painted.where((p) => p.$2 == entry.value), isEmpty,
reason: '${entry.key} must not appear in the accepted state either');
}
});
testWidgets('type colour reaches the chip and nothing else', (tester) async {
await pumpCard(tester);
// Present: the three darkened chip fills, one per stop kind.
final containerFills = tester.allWidgets
.whereType<Container>()
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.map((d) => d.color)
.whereType<Color>()
.toList();
for (final chip in [
ColorConstants.pickupChip,
ColorConstants.deliveryChip,
ColorConstants.combinedChip,
]) {
expect(containerFills, contains(chip),
reason: 'the type chip should be filled with its darkened accent');
}
// Absent: any of those three used as a *border*. A chip is a mark; a border
// belongs to a surface, and that is the line being held.
final borderColours = tester.allWidgets
.whereType<Container>()
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.map((d) => d.border)
.whereType<Border>()
.expand((b) => [b.top.color, b.right.color, b.bottom.color, b.left.color])
.toList();
for (final chip in [
ColorConstants.pickupChip,
ColorConstants.deliveryChip,
ColorConstants.combinedChip,
]) {
expect(borderColours, isNot(contains(chip)),
reason: 'stop type must not outline a surface');
}
});
testWidgets('stop cards are borderless, on one fill whatever the type',
(tester) async {
await pumpCard(tester);
// The stop bodies: a rounded fill with no border at all now.
final bodies = tester.allWidgets
.whereType<Container>()
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.where((d) => d.color == ColorConstants.cardSurface)
.toList();
expect(bodies.length, 3, reason: 'one fill per stop, all three identical');
for (final d in bodies) {
expect(d.border, isNull, reason: 'the card carries no border');
expect(d.boxShadow, anyOf(isNull, isEmpty),
reason: 'the fill does the separating, not an elevation');
}
});
testWidgets('type is stated in words, not colour', (tester) async {
await pumpCard(tester);
// The labels still say which kind of stop each one is — that information was
// never removed, only moved out of the fill and into the text.
//
// The combined chip reads "P&D", not "PICKUP & DELIVERY". On a stop row the
// chip shares a line with a duration, a call button and a tick box, all of
// which have a fixed claim on the width, so 17 characters ellipsised down to
// "P…" — a chip that has to truncate is not a chip. The full wording still
// appears on the Bookings card, which has a whole line for it.
expect(find.text('PICKUP'), findsWidgets);
expect(find.text('DELIVERY'), findsWidgets);
expect(find.text('P&D'), findsWidgets);
expect(find.text('PICKUP & DELIVERY'), findsNothing,
reason: 'the long label does not fit a stop-row chip');
});
testWidgets('the type chip is never squeezed out of shape', (tester) async {
// The bug: the chip rendered as "P…". Its `Flexible` sat beside a `Spacer`,
// and a Spacer is an `Expanded` — so the two split the free width evenly and
// the chip was capped at about 23pt against a 63pt label, i.e. **36% of what
// it needed**, no matter how short the wording was.
//
// Asserted as a *ratio* to the label's own natural width rather than as a
// fixed number of points. The headless test font draws every glyph as a full
// em square — roughly twice a real font — so absolute widths here mean
// nothing, but "how much of what it asked for did it get" survives the
// difference. A flat floor would also wrongly fail "P&D", which is three
// characters and legitimately narrow.
const squeezed = 0.36; // what the broken layout gave
const acceptable = 0.6;
for (final width in [320.0, 360.0, 390.0, 430.0]) {
await pumpCard(tester, width: width);
expect(tester.takeException(), isNull,
reason: 'the meta row overflowed at ${width.toInt()}pt');
for (final label in ['PICKUP', 'DELIVERY', 'P&D']) {
final finder = find.text(label);
if (finder.evaluate().isEmpty) continue;
final box = finder.evaluate().first.renderObject! as RenderBox;
final natural = (TextPainter(
text: TextSpan(
text: label,
style: tester.widget<Text>(finder.first).style,
),
textDirection: TextDirection.ltr,
)..layout())
.width;
final got = box.size.width / natural;
expect(got, greaterThan(acceptable),
reason: 'at ${width.toInt()}pt the "$label" chip got '
'${(got * 100).round()}% of the width its label needs; the '
'Spacer-stealing bug gave ${(squeezed * 100).round()}%');
}
}
});
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/stop_compliance.dart';
/// The two verdicts the hub pays on. Both were already being measured — the
/// bonus rule is "before the ETA" and the distance is measured to bill rider
/// charges — and neither reached the rider until now, so the arithmetic behind
/// them is worth pinning.
void main() {
group('one stop', () {
test('on time and within the route tolerance is clean', () {
const c = StopCompliance(onTime: true, actualKm: 5.4, plannedKm: 5.0);
expect(c.onRoute, isTrue);
expect(c.isClean, isTrue);
expect(c.extraKm, isNull);
});
test('a wander past the tolerance is off route, and says by how much', () {
const c = StopCompliance(onTime: true, actualKm: 12.0, plannedKm: 5.0);
expect(c.onRoute, isFalse);
expect(c.extraKm, 7.0);
expect(c.isClean, isFalse, reason: 'both halves have to hold');
});
// Traffic, one-ways and diversions are the rider's normal, not a detour.
test('ordinary slack does not count as leaving the route', () {
const near = StopCompliance(actualKm: 6.2, plannedKm: 5.0); // +24%
expect(near.onRoute, isTrue);
const grace = StopCompliance(actualKm: 1.4, plannedKm: 1.0); // short hop
expect(grace.onRoute, isTrue,
reason: 'a fixed grace keeps short stops from failing on rounding');
});
test('late is not clean, however good the route', () {
const c = StopCompliance(
onTime: false,
lateBy: Duration(minutes: 9),
actualKm: 4.0,
plannedKm: 5.0,
);
expect(c.onRoute, isTrue);
expect(c.isClean, isFalse);
});
// A hub that set no ETA, a GPS that never resolved: not the rider's fault
// and never shown as a miss.
test('missing inputs are unknown, not failures', () {
const nothing = StopCompliance();
expect(nothing.isUnknown, isTrue);
expect(nothing.onRoute, isNull);
expect(nothing.isClean, isFalse);
const zeroPlan = StopCompliance(actualKm: 3.0, plannedKm: 0);
expect(zeroPlan.onRoute, isNull,
reason: 'a planned distance of zero is missing, not perfect');
});
});
group('reading a stored stop', () {
test('prefers the stamp written at completion', () {
final c = StopCompliance.of({
'compliance': {
'ontime': true,
'latebyseconds': 0,
'actualkm': 4.0,
'plannedkm': 4.0,
},
'riderkms': '99',
'kms': '1',
});
expect(c.onTime, isTrue);
expect(c.actualKm, 4.0);
expect(c.onRoute, isTrue);
});
test('falls back to the backend row when there is no stamp', () {
final c = StopCompliance.of({'riderkms': '9.0', 'kms': '4'});
expect(c.onTime, isNull);
expect(c.onRoute, isFalse, reason: '9km against a 4km plan is a wander');
});
});
group('the day', () {
test('scores only what could be measured', () {
final summary = ComplianceSummary.from([
{
'compliance': {'ontime': true, 'actualkm': 4.0, 'plannedkm': 4.0}
},
{
'compliance': {'ontime': false, 'actualkm': 4.0, 'plannedkm': 4.0}
},
{
'compliance': {'ontime': true, 'actualkm': 20.0, 'plannedkm': 4.0}
},
{'orderid': 'no-data'},
]);
expect(summary.measured, 3);
expect(summary.unmeasured, 1, reason: 'and it is not in the denominator');
expect(summary.onTime, 2);
expect(summary.onRoute, 2);
expect(summary.clean, 1);
expect(summary.points, 1, reason: 'a point per stop that cleared both');
expect(summary.onTimePercent, 67);
});
test('an empty day scores nothing rather than dividing by zero', () {
final summary = ComplianceSummary.from(const []);
expect(summary.points, 0);
expect(summary.onTimePercent, 0);
expect(summary.cleanPercent, 0);
});
});
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
/// The call button and the tick box on a Home stop row must sit on the same two
/// columns for every stop in the route.
///
/// They did not. Both were *conditional* children of the meta row —
/// `if (phone.isNotEmpty)` and `if (pending && onToggleSelect != null)` — so
/// whenever one was missing the other slid across to take its place. A stop with
/// no phone number pushed its tick box a tap-target to the right; an
/// already-accepted stop pushed its call button the same way. Down a route of
/// mixed states the two controls zig-zagged, and a rider reaching for "the tick"
/// had to locate it again on every row.
///
/// This is the kind of thing that is obvious in a screenshot and invisible in
/// code review, so it is measured: the x-positions are compared across a trip
/// deliberately built to hit every combination.
void main() {
Map<String, dynamic> stop(
String id, {
required String type,
String? phone,
}) =>
{
'orderid': id,
'type': type,
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Coimbatore',
if (phone != null) 'pickupcontactno': phone,
'Quantity': '2',
'expected_pickup_time': DateTime.now().toIso8601String(),
};
/// A route covering every trailing-slot combination.
///
/// The states are the three Home still lists — pending, rejected, skipped.
/// Accepted stops used to fill the "no tick box" cases here, but they leave
/// Home for Bookings now, so a decided-but-still-listed state does the job:
///
/// 1 pending + phone → both controls
/// 2 pending, no phone → tick only
/// 3 rejected + phone → call only (no tick: it is already decided)
/// 4 skipped, no phone → neither
Future<void> pumpRoute(WidgetTester tester) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final trip = Trip.fromStops(id: 'mixed', stops: [
stop('A', type: 'pickup', phone: '9876543210'),
stop('B', type: 'delivery'),
{...stop('C', type: 'combined', phone: '9876500000')},
{...stop('D', type: 'pickup'), 'orderstatus': 'skipped'},
]);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: trip,
rejectedIds: const {'C'},
onToggleSelect: (_) {},
onCallStop: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('every call button shares one column', (tester) async {
await pumpRoute(tester);
final calls = tester
.widgetList<Widget>(find.byIcon(Icons.call_rounded))
.toList();
expect(calls.length, 2, reason: 'two of the four stops carry a phone');
final rects = find
.byIcon(Icons.call_rounded)
.evaluate()
.map((e) => (e.renderObject! as RenderBox).localToGlobal(Offset.zero).dx)
.toList();
expect(rects.first, closeTo(rects.last, 0.01),
reason: 'the call buttons are at x=$rects — they must share a column '
'even though one stop is pending and the other rejected');
});
testWidgets('the call column does not move when the tick box is absent',
(tester) async {
await pumpRoute(tester);
// Stop A is pending and has a phone, so it shows BOTH controls.
// Stop C is rejected and has a phone, so it shows the call button ALONE.
// Before the fix, C's call button sat one tap-target further right.
final callXs = find
.byIcon(Icons.call_rounded)
.evaluate()
.map((e) => (e.renderObject! as RenderBox).localToGlobal(Offset.zero).dx)
.toSet();
expect(callXs.length, 1,
reason: 'a missing tick box must not pull the call button across');
});
testWidgets('the card and the rail each get their side gap', (tester) async {
await pumpRoute(tester);
// The rail node is the numbered circle; the card is the fill beside it.
final railLeft = tester.getRect(find.text('1')).left;
expect(railLeft, greaterThan(12),
reason: 'the rail should not run into the page gutter');
// Nothing is clipped or overflowing at the other edge either.
expect(tester.takeException(), isNull);
});
testWidgets('the two controls are the same size', (tester) async {
await pumpRoute(tester);
final call = tester.getSize(find.byIcon(Icons.call_rounded).first);
expect(call.width, call.height, reason: 'the call button is square');
// Both controls render inside equal-width reserved slots.
expect(tester.takeException(), isNull);
});
testWidgets('every duration sits on one right-hand column', (tester) async {
await pumpRoute(tester);
// "~5 min" is materially narrower than "~10 min". Left-aligned, that put the
// duration in a different place on every card and dragged the call button
// and tick box along with it. Each now sits in a fixed-width slot,
// right-aligned, so all four share a trailing edge.
final rights = find
.textContaining('~')
.evaluate()
.map((e) {
final box = e.renderObject! as RenderBox;
return box.localToGlobal(Offset.zero).dx + box.size.width;
})
.toSet();
expect(rights.length, 1,
reason: 'durations end at $rights — they must share one column');
});
testWidgets('the three trailing elements keep a constant spacing',
(tester) async {
await pumpRoute(tester);
double leftOf(Finder f) =>
(f.evaluate().first.renderObject! as RenderBox)
.localToGlobal(Offset.zero)
.dx;
// Duration, then call, then tick — in that order, left to right, and the
// gaps between them are identical on every card because all three are
// fixed-width slots rather than content-sized children.
final duration = leftOf(find.textContaining('~'));
final call = leftOf(find.byIcon(Icons.call_rounded));
expect(duration, lessThan(call),
reason: 'the duration reads before the controls it belongs to');
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ── The form is the screen; the boxes were not ──
///
/// Every group on a verification leg used to be a filled, rounded, 16pt-padded
/// panel — the stop summary, the parcel checklist, the recipient picker, the
/// OTP, the photo — four to six of them stacked, each holding white controls.
/// That put a *container* rank and a *control* rank on the page at the same
/// weight, when only one of the two is something the rider touches at a door.
///
/// They are named and ruled off instead. These tests pin the parts of that
/// which a later change could quietly undo.
void main() {
Future<void> pump(
WidgetTester tester,
Map<String, dynamic> stop, {
double width = 390,
}) async {
tester.view.physicalSize = Size(width * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) =>
MaterialApp(home: StopVerificationPage(pickup: stop)),
),
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stopOf({
required String type,
int deliveryQty = 0,
int pickupQty = 0,
}) => <String, dynamic>{
'orderid': 'T-1',
'type': type,
'pickupcustomer': 'Priya Sharma',
'pickupaddress': '45, Lake View Apartments, Race Course, Coimbatore',
if (deliveryQty > 0) 'deliveryqty': '$deliveryQty',
if (pickupQty > 0) 'pickupqty': '$pickupQty',
'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}',
};
/// The width of a chip, measured on the box that actually draws it.
double chipWidth(WidgetTester tester, String label) => tester
.getSize(
find
.ancestor(
of: find.text(label),
matching: find.byType(AnimatedContainer),
)
.first,
)
.width;
testWidgets('a leg step draws no filled panels', (tester) async {
await pump(tester, stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2));
// `cardSurface` is the panel fill. It is still right for the review step —
// one grey block per leg, on the screen the rider signs — but on a step he
// is filling in, every one of them was a box around work rather than the
// work itself.
final fills = tester
.widgetList<Container>(
find.descendant(
of: find.byType(SingleChildScrollView),
matching: find.byType(Container),
),
)
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.map((d) => d.color);
expect(fills, isNot(contains(ColorConstants.cardSurface)));
});
testWidgets('section names are quiet labels, not headings', (tester) async {
await pump(tester, stopOf(type: 'delivery', deliveryQty: 1));
// Small, letterspaced and grey — the group is named without competing with
// the controls inside it.
final label = tester.widget<Text>(find.text('WHO RECEIVED IT?'));
expect(label.style!.color, ColorConstants.secondaryText);
expect(label.style!.letterSpacing, greaterThan(0.8));
// Quieter than the stop it belongs to, which is itself only context.
final name = tester.widget<Text>(find.text('Priya Sharma'));
expect(label.style!.fontSize, lessThan(name.style!.fontSize!));
});
testWidgets('the pickers are an even grid, with no half-width orphan', (
tester,
) async {
await pump(tester, stopOf(type: 'delivery', deliveryQty: 1));
// Four recipients: two rows of two, all the same width. Natural-width chips
// packed themselves three-then-one, which reads as a list that ran out
// rather than as a set of four equal choices.
final customer = chipWidth(tester, 'Customer');
for (final other in const ['Family', 'Security', 'Neighbour']) {
expect(chipWidth(tester, other), closeTo(customer, 0.5));
}
});
testWidgets('an odd picker gives its last option the full row', (
tester,
) async {
await pump(tester, stopOf(type: 'pickup', pickupQty: 2));
// Three conditions. The third at half width on a line of its own is an
// orphan; at full width it is a second row.
expect(
chipWidth(tester, 'Damaged'),
greaterThan(chipWidth(tester, 'Sealed & intact') * 1.8),
);
});
testWidgets('a blocked step gets a neutral bar, not a faded coloured one', (
tester,
) async {
await pump(tester, stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2));
// Nothing has been filled in, so the step is blocked.
expect(find.text('1 parcel still to hand over'), findsOneWidget);
// No accent button at all — a saturated pill at 45% still reads as a
// button, so the rider taps it and nothing happens.
expect(find.byType(MilerButton), findsNothing);
expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget);
// And the label stays readable: grey type on a neutral fill, not white
// dropped to 45% over a wash of itself.
// Named for where it goes, not for which step it is: the rider is on the
// hand-over leg, so the button announces the collection that follows it.
final label = tester.widget<Text>(find.text('Continue to collect'));
expect(label.style!.color, ColorConstants.secondaryText);
});
testWidgets('completing the step turns the bar a solid accent', (
tester,
) async {
await pump(tester, stopOf(type: 'delivery', deliveryQty: 1));
await tester.tap(find.text('Parcel 1 of 1'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, '482916');
await tester.pumpAndSettle();
// Photo is the last gate and the camera cannot be reached here, so the bar
// is still blocked — but on the name of what is missing, not the parcels.
expect(find.text('Take a proof photo'), findsOneWidget);
// The parcel row and the OTP now report themselves done, which is the
// section-scoped tick: it speaks for its own step, never for the screen.
expect(find.text('Handed over'), findsOneWidget);
});
testWidgets('the app bar clears the status bar', (tester) async {
await pump(tester, stopOf(type: 'delivery', deliveryQty: 1));
// The title used to sit hard against the clock, which is where a system
// default puts it and nowhere near where the rest of the app puts a
// heading. `toolbarHeight` alone only moves it half as far as it adds.
final title = tester.getRect(find.text('Verify delivery'));
expect(title.top, greaterThan(24.0));
});
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
/// The verification page is filled in at a doorstep, one-handed, often by
/// someone who has turned the system font up. It was rebuilt to remove the
/// nested boxes it used to be made of, and the pieces that replaced them —
/// a step counter in the app bar, a full-width parcel list, six OTP tiles, a
/// label/value review — are all row layouts with fixed-width parts, which is
/// exactly the shape that overflows when the text grows.
///
/// A screenshot on the developer's own phone at 1.0x cannot catch that, so it
/// is measured here: every leg, on the narrowest phone the app supports, at
/// every text scale Android offers.
void main() {
Future<void> pump(
WidgetTester tester,
Map<String, dynamic> stop, {
double width = 360,
double textScale = 1.0,
}) async {
tester.view.physicalSize = Size(width * 3, 780 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context)
.copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: StopVerificationPage(pickup: stop),
),
),
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stopOf(String type, {int delivery = 0, int pickup = 0}) =>
<String, dynamic>{
'orderid': 'T-1',
'type': type,
'pickupcustomer': 'Venkateswara Traders & General Merchants',
'pickupaddress':
'12/3B, Second Cross Street, Ram Nagar, Near Bus Stand, '
'Coimbatore, Tamil Nadu 641009',
if (delivery > 0) 'deliveryqty': '$delivery',
if (pickup > 0) 'pickupqty': '$pickup',
'Quantity': '${pickup > 0 ? pickup : delivery}',
'collectionamt': '2450',
};
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
testWidgets('the collect leg lays out at ${scale}x', (tester) async {
await pump(tester, stopOf('pickup', pickup: 3), textScale: scale);
expect(tester.takeException(), isNull);
});
testWidgets('the hand-over leg lays out at ${scale}x', (tester) async {
await pump(tester, stopOf('delivery', delivery: 4), textScale: scale);
expect(tester.takeException(), isNull);
});
}
testWidgets('the whole collect leg is reachable by scrolling',
(tester) async {
await pump(tester, stopOf('pickup', pickup: 2));
// The last section of the longest leg. If the scroll view were sized wrong
// — which is the failure mode of a Column inside a Column inside an
// Expanded — this would never come into view.
await tester.dragUntilVisible(
find.text('PICKUP CODE'),
find.byType(SingleChildScrollView),
const Offset(0, -200),
);
expect(find.text('PICKUP CODE'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the action bar never scrolls away with the form',
(tester) async {
await pump(tester, stopOf('pickup', pickup: 2));
final barAtRest = tester.getRect(find.text('Take a photo of the parcels'));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -400));
await tester.pumpAndSettle();
// The blocking hint lives in the pinned bar, so it is still exactly where
// it was: what the rider must do next cannot scroll off a doorstep screen.
expect(
tester.getRect(find.text('Take a photo of the parcels')),
barAtRest,
);
});
}

View File

@@ -0,0 +1,243 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
/// The review step used to be untestable — "it sits behind a camera gate no
/// widget test can pass" — so the only thing held about it was the pure packing
/// rule in [packReviewFacts]. That left the screen itself unwatched, and it
/// broke: the fact rows were `Row(crossAxisAlignment: stretch)` inside a
/// `SingleChildScrollView`, which asks the row to stretch to an infinite height.
/// Layout threw, the exception propagated up through the leg panel's Column, and
/// the last screen of the flow — the one the rider signs — showed no figures at
/// all.
///
/// The gate is passable: [ImagePickerPlatform.instance] is the seam. Swapping in
/// a fake that returns a real file on disk lets the whole flow run to the review
/// and assert what the rider is actually shown.
class _FakeCamera extends ImagePickerPlatform {
final String path;
_FakeCamera(this.path);
@override
Future<XFile?> getImageFromSource({
required ImageSource source,
ImagePickerOptions options = const ImagePickerOptions(),
}) async => XFile(path);
}
void main() {
setUpAll(() async {
final dir = await Directory.systemTemp.createTemp('miler_review');
final png = File('${dir.path}/proof.png')
// A 1×1 PNG: Image.file needs bytes it can decode, not a real photo.
..writeAsBytesSync(const <int>[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, //
0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137,
0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 250, 207, 192, 240,
31, 0, 5, 5, 2, 0, 141, 88, 175, 91, 0, 0, 0, 0, 73, 69,
78, 68, 174, 66, 96, 130,
]);
ImagePickerPlatform.instance = _FakeCamera(png.path);
});
Future<void> pump(
WidgetTester tester,
Map<String, dynamic> stop, {
double width = 390,
double textScale = 1.0,
}) async {
tester.view.physicalSize = Size(width * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: StopVerificationPage(pickup: stop),
),
),
);
await tester.pumpAndSettle();
}
/// Taps by visible label, scrolling it into view first — every one of these
/// controls sits below the fold on a phone.
Future<void> tap(WidgetTester tester, String label) async {
final target = find.text(label).first;
await tester.ensureVisible(target);
await tester.pumpAndSettle();
await tester.tap(target);
await tester.pumpAndSettle();
}
Future<void> type(WidgetTester tester, String hint, String value) async {
await tester.enterText(
find.byWidgetPredicate(
(w) => w is TextField && w.decoration?.hintText == hint,
),
value,
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stopOf({
required String type,
int deliveryQty = 0,
int pickupQty = 0,
double cash = 0,
}) => <String, dynamic>{
'orderid': 'T-1',
'type': type,
'pickupcustomer': 'Priya Sharma',
'pickupaddress': '45, Lake View Apartments, Coimbatore',
if (deliveryQty > 0) 'deliveryqty': '$deliveryQty',
if (pickupQty > 0) 'pickupqty': '$pickupQty',
'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}',
if (cash > 0) 'collectionamt': '$cash',
};
testWidgets('a pickup stop shows every collected figure on the review', (
tester,
) async {
await pump(tester, stopOf(type: 'pickup', pickupQty: 3, cash: 2450));
await type(tester, 'Total weight in kg', '4.5');
await tap(tester, 'Take photo');
// The pickup code is six boxes over one hidden field, not a hinted one.
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Review');
// The heading is the screen's own voice, not another section title.
expect(find.text('Check before you confirm'), findsOneWidget);
// Every figure the rider is signing for. Before the layout fix this whole
// block rendered as nothing.
expect(find.text('COLLECTED'), findsOneWidget);
expect(find.text('3 of 3'), findsOneWidget);
expect(find.text('4.5 kg'), findsOneWidget);
expect(find.text('Sealed & intact'), findsOneWidget);
expect(find.text('482156'), findsOneWidget);
// The photo is shown, not asserted as a tick the rider cannot check.
expect(find.byType(Image), findsWidgets);
expect(find.text('Tap to enlarge'), findsOneWidget);
// Money is flagged before the commit, not after it.
expect(
find.text('Collect ₹2450 from the customer on the next screen'),
findsOneWidget,
);
expect(find.text('Confirm pickup'), findsOneWidget);
});
testWidgets('a combined stop reviews both legs separately', (tester) async {
await pump(tester, stopOf(type: 'combined', deliveryQty: 2, pickupQty: 3));
// Hand over.
await tap(tester, 'Parcel 1 of 2');
await tap(tester, 'Parcel 2 of 2');
await tap(tester, 'Security');
await type(tester, "Person's name", 'Ramesh Kumar');
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Take photo');
await tap(tester, 'Continue to collect');
// Collect, with a damaged seal so the note takes its own wide row.
await tap(tester, 'Damaged');
await type(tester, 'Total weight in kg', '12.75');
await type(
tester,
'e.g. corner of the box is crushed',
'Corner of the second box is crushed',
);
await tap(tester, 'Take photo');
await tap(tester, 'Review');
// Two panels, each with its own facts and its own photo — the pairing is
// the whole reason both shots are taken.
expect(find.text('HANDED OVER'), findsOneWidget);
expect(find.text('2 of 2'), findsOneWidget);
expect(find.text('Ramesh Kumar · Security'), findsOneWidget);
expect(find.text('COLLECTED'), findsOneWidget);
expect(find.text('3 of 3'), findsOneWidget);
expect(find.text('12.75 kg'), findsOneWidget);
expect(find.text('Damaged'), findsOneWidget);
expect(find.text('Corner of the second box is crushed'), findsOneWidget);
expect(find.text('Tap to enlarge'), findsNWidgets(2));
// Both legs are complete, and each says so on itself — on a combined stop
// the page-level verdict does not say which job is short.
expect(find.text('Complete'), findsNWidgets(2));
// No pickup code on a combined stop: the delivery OTP already proved the
// door, so it is never collected and must not be reviewed.
expect(find.text('Pickup code'), findsNothing);
});
testWidgets('tapping a proof photo opens it full frame', (tester) async {
await pump(tester, stopOf(type: 'pickup', pickupQty: 1));
await type(tester, 'Total weight in kg', '4.5');
await tap(tester, 'Take photo');
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Review');
await tap(tester, 'Tap to enlarge');
expect(find.byType(InteractiveViewer), findsOneWidget);
});
// The review is a grid of fixed-width halves, which is the shape that
// overflows when the system font grows. Measured rather than eyeballed.
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
testWidgets('the review lays out at ${scale}x on a 360pt phone', (
tester,
) async {
await pump(
tester,
stopOf(type: 'combined', deliveryQty: 2, pickupQty: 3, cash: 2450),
width: 360,
textScale: scale,
);
await tap(tester, 'Parcel 1 of 2');
await tap(tester, 'Parcel 2 of 2');
await tap(tester, 'Security');
await type(tester, "Person's name", 'Ramesh Kumar Subramaniam');
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Take photo');
await tap(tester, 'Continue to collect');
await tap(tester, 'Damaged');
await type(tester, 'Total weight in kg', '12.75');
await type(
tester,
'e.g. corner of the box is crushed',
'Corner of the second box is crushed on the underside',
);
await tap(tester, 'Take photo');
await tap(tester, 'Review');
expect(find.text('HANDED OVER'), findsOneWidget);
expect(find.text('COLLECTED'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}

162
test/stop_verify_test.dart Normal file
View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
/// The verification page is the only place the app records what actually
/// happened at a door, so the thing worth testing is the gate: which legs it
/// runs for each stop kind, and that it refuses to advance until the proof for
/// the current leg exists.
void main() {
/// Pumps the page on a phone-shaped surface. The default 800×600 test window
/// makes ScreenUtil scale width up and height down at once, which is not a
/// shape any phone has — layouts that are fine on device fail there for the
/// wrong reason.
Future<void> pumpStop(WidgetTester tester, Map<String, dynamic> stop) async {
tester.view.physicalSize = const Size(1170, 2532); // iPhone 13, @3x
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) =>
MaterialApp(home: StopVerificationPage(pickup: stop)),
),
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stopOf({
required String type,
int deliveryQty = 0,
int pickupQty = 0,
double cash = 0,
}) =>
<String, dynamic>{
'orderid': 'T-1',
'type': type,
'pickupcustomer': 'Priya Sharma',
'pickupaddress': '45, Lake View Apartments, Coimbatore',
if (deliveryQty > 0) 'deliveryqty': '$deliveryQty',
if (pickupQty > 0) 'pickupqty': '$pickupQty',
'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}',
if (cash > 0) 'collectionamt': '$cash',
};
testWidgets('a pickup stop runs one collect leg', (tester) async {
await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 3));
expect(find.text('Verify pickup'), findsOneWidget);
expect(find.text('Collect'), findsOneWidget); // stepper
expect(find.text('Confirm'), findsOneWidget);
expect(find.text('Hand over'), findsNothing);
// The collect leg's own controls.
expect(find.text('PARCELS COLLECTED'), findsOneWidget);
expect(find.text('of 3 booked'), findsOneWidget);
expect(find.text('PICKUP CODE'), findsOneWidget);
});
testWidgets('a delivery stop runs one hand-over leg with a parcel checklist',
(tester) async {
await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 2));
expect(find.text('Verify delivery'), findsOneWidget);
expect(find.text('Hand over'), findsOneWidget);
expect(find.text('Collect'), findsNothing);
// One tile per parcel — this is what stops a 2-parcel drop closing on 1.
expect(find.text('Parcel 1 of 2'), findsOneWidget);
expect(find.text('Parcel 2 of 2'), findsOneWidget);
expect(find.text('WHO RECEIVED IT?'), findsOneWidget);
expect(find.text('DELIVERY OTP'), findsOneWidget);
// Nothing about weight or condition on a delivery.
expect(find.text('Condition'), findsNothing);
});
testWidgets('a combined stop runs hand over, then collect', (tester) async {
await pumpStop(
tester, stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2));
expect(find.text('Verify stop'), findsOneWidget);
// Three steps, delivery first — the rider walks up holding the outbound
// parcel, so he hands it over before taking anything back.
expect(find.text('Hand over'), findsOneWidget);
expect(find.text('Collect'), findsOneWidget);
expect(find.text('Confirm'), findsOneWidget);
// Both loads are on the summary.
expect(find.text('Hand over 1'), findsOneWidget);
expect(find.text('Collect 2'), findsOneWidget);
// Step 1 is the delivery leg.
expect(find.text('HAND OVER THE PARCELS'), findsOneWidget);
expect(find.text('PARCELS COLLECTED'), findsNothing);
});
testWidgets('the delivery leg will not advance without its proof',
(tester) async {
await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 1));
// Blocked from the start, and the bar says what is missing next.
expect(find.text('1 parcel still to hand over'), findsOneWidget);
await tester.tap(find.text('Parcel 1 of 1'));
await tester.pumpAndSettle();
expect(find.text('Enter the OTP from the customer'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '482156');
await tester.pumpAndSettle();
expect(find.text('Take a proof photo'), findsOneWidget);
// Still on step 1 — the camera is the last gate and cannot be faked here.
expect(find.text('HAND OVER THE PARCELS'), findsOneWidget);
});
testWidgets('handing over to a third party demands their name',
(tester) async {
await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 1));
await tester.tap(find.text('Parcel 1 of 1'));
await tester.pumpAndSettle();
expect(find.text("Person's name"), findsNothing);
await tester.tap(find.text('Security'));
await tester.pumpAndSettle();
expect(find.text("Person's name"), findsOneWidget);
expect(find.text('Enter the name of the person who took it'),
findsOneWidget);
});
testWidgets('a short count forces a written reason', (tester) async {
await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 3));
// Pre-set to the booked count, so the common case needs no explanation.
expect(find.text('3'), findsOneWidget);
expect(find.text('WHAT HAPPENED?'), findsNothing);
await tester.tap(find.byIcon(Icons.remove_rounded));
await tester.pumpAndSettle();
expect(find.text('1 fewer than the parcels booked'), findsOneWidget);
expect(find.text('WHAT HAPPENED?'), findsOneWidget);
});
testWidgets('damaged goods force a written reason too', (tester) async {
await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 1));
expect(find.text('WHAT HAPPENED?'), findsNothing);
await tester.tap(find.text('Damaged'));
await tester.pumpAndSettle();
expect(find.text('WHAT HAPPENED?'), findsOneWidget);
});
testWidgets('COD is surfaced on the stop summary', (tester) async {
await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 1, cash: 720));
expect(find.text('₹720 to collect'), findsOneWidget);
});
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/Dashboard/activity/activity_page.dart';
import 'package:miler/views/Dashboard/summary/summary.dart';
import 'package:miler/views/offline/offline_banner.dart';
/// ── Riders turn the system font up ──
///
/// An audit found the screens with layout tests (verify, collect payment,
/// profile, trip tabs) solid at 2×, and everything else unguarded: 443 `Text`
/// widgets against 109 `maxLines`, and exactly one file in 102 reading the text
/// scaler.
///
/// The failure mode is not cosmetic. A `Row` with a fixed-width child overflows
/// into the black-and-yellow stripe bar, which on a customer's doorstep looks
/// like the app has crashed. Earnings was the worst of them — a 46sp hero
/// figure sharing a row with labels — which is why that figure is now on the
/// type scale.
///
/// This sweeps the screens that had no coverage. It asserts no exception is
/// thrown, which is exactly what a `RenderFlex` overflow does.
void main() {
Future<void> pump(
WidgetTester tester,
Widget page, {
double width = 360,
double scale = 1.0,
}) async {
tester.view.physicalSize = Size(width * 3, 780 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
minTextAdapt: true,
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(scale)),
child: child!,
),
home: page,
),
),
);
await tester.pump(const Duration(milliseconds: 400));
}
// The four scales Android offers, on the narrowest phone the app supports.
const scales = [1.0, 1.3, 1.5, 2.0];
group('Earnings', () {
for (final s in scales) {
testWidgets('lays out at ${s}x', (tester) async {
SharedPreferences.setMockInitialValues({'userId': 1});
await pump(tester, const Summary(), scale: s);
expect(tester.takeException(), isNull);
});
}
});
group('Activity', () {
for (final s in scales) {
testWidgets('lays out at ${s}x', (tester) async {
SharedPreferences.setMockInitialValues({'userId': 1});
await pump(tester, const ActivityPage(), scale: s);
expect(tester.takeException(), isNull);
});
}
});
// ── Sign In is covered by `auth_design_test.dart`, not here ──
//
// The screen calls `NotificationServce.initialize` in `initState`, which
// needs a platform channel a widget test has no way to answer. That is a test
// harness limitation rather than a layout finding: the auth *components* —
// the code row, the scaffold, the primary button, the inline error — are
// pumped and asserted directly in `auth_design_test.dart`, which is where the
// design surface actually lives.
group('the offline strip', () {
for (final s in scales) {
testWidgets('lays out at ${s}x without covering the job', (tester) async {
await pump(
tester,
const OfflineScope(
offline: true,
child: Scaffold(
body: Center(child: Text('45, Lake View Apartments, Coimbatore')),
),
),
scale: s,
);
expect(tester.takeException(), isNull);
// Still readable, still not on top of anything.
expect(find.text('45, Lake View Apartments, Coimbatore'), findsOneWidget);
});
}
});
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_brief_strip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// The trip brief and the hub row have to survive every phone and every
/// accessibility text size.
///
/// This exists because both of them broke in exactly that way while being
/// written. Putting "VIEW ROUTE" on the START · HUB row overflowed the row by
/// 6.2px at the design width — a `Row` of two natural-width children with a
/// `Spacer` between them has no give at all, and the failure only appears once
/// the two happen to be too wide together. The brief has the same shape of risk
/// in two places: four figures sharing one row, and a value like `₹1,23,456`
/// that is far wider than the `₹450` anyone tests with by hand.
///
/// So this sweeps the four widths the app actually ships to against the text
/// scales Android exposes in Settings, and asserts only one thing: nothing
/// throws. Overflow in Flutter is a layout *exception*, so that single
/// assertion catches all of it.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
/// 320 is the narrowest Android phone still in the field; 430 is a Pro Max.
const widths = [320.0, 360.0, 390.0, 430.0];
/// 2.0 is the top of Android's font-size slider.
const textScales = [1.0, 1.3, 1.5, 2.0];
/// A worst-case trip: six stops, and a cash figure with enough digits to
/// stress the narrowest of the four figure columns.
Trip bigTrip() => Trip.fromStops(
id: 't1',
stops: [
for (var i = 0; i < 6; i++)
{
'orderid': 'S$i',
'type': 'pickup',
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore',
'Quantity': '3',
'collectionamt': '123456',
'expected_pickup_time': DateTime.now().toIso8601String(),
},
],
);
for (final width in widths) {
for (final scale in textScales) {
testWidgets('lays out at ${width.toInt()}pt / ${scale}x', (tester) async {
tester.view.physicalSize = Size(width * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
child: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: bigTrip(),
// Supplied, so the hub row carries VIEW ROUTE — the
// element that overflowed.
onViewRoute: () {},
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(
tester.takeException(),
isNull,
reason: 'collapsed brief overflowed at ${width}pt / ${scale}x',
);
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
expect(
tester.takeException(),
isNull,
reason: 'open brief overflowed at ${width}pt / ${scale}x',
);
});
}
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip_empty_state.dart';
/// An unfilled trip tab used to render a bordered white card with a grey
/// glyph in it — a piece of content that had arrived empty. It is the page
/// itself now, so the assertion that matters is that no card comes back: a
/// `Container` with a `Border` around the whole state is the thing being
/// removed, and a redesign that quietly reintroduces one should fail here.
void main() {
Future<void> pump(
WidgetTester tester, {
required bool offline,
VoidCallback? onGoOnDuty,
}) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: TripEmptyState(
tripLabel: 'Trip 2',
offline: offline,
onGoOnDuty: onGoOnDuty,
),
),
),
),
);
// Not `pumpAndSettle`: the halo breathes on a repeating controller, so
// there is deliberately no steady state to settle into.
await tester.pump(const Duration(milliseconds: 300));
}
/// Any decoration carrying a border, anywhere in the state.
Finder borderedBoxes() => find.byWidgetPredicate((w) {
if (w is! Container) return false;
final d = w.decoration;
return d is BoxDecoration && d.border != null;
});
testWidgets('names the trip the tabs name, and draws no card', (
tester,
) async {
await pump(tester, offline: false);
expect(find.text('Trip 2 not assigned yet'), findsOneWidget);
expect(borderedBoxes(), findsNothing);
});
testWidgets('says the app is still waiting rather than offering nothing', (
tester,
) async {
await pump(tester, offline: false);
expect(find.text('Waiting for your hub'), findsOneWidget);
});
testWidgets('off duty is a different sentence with the action that fixes it', (
tester,
) async {
var tapped = false;
await pump(tester, offline: true, onGoOnDuty: () => tapped = true);
expect(find.text('You are off duty'), findsOneWidget);
// Nothing is coming until he goes on duty, so the wait is not claimed.
expect(find.text('Waiting for your hub'), findsNothing);
await tester.tap(find.text('Go on duty'));
await tester.pump();
expect(tapped, isTrue);
});
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_progress_rail.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<StopProgress> 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<void> pump(WidgetTester tester, List<StopProgress> 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<StopProgress> 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<Container>(find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(Container),
))
.map((c) => c.decoration)
.whereType<BoxDecoration>();
for (final d in boxes) {
expect(d.border, isNull, reason: 'no bordered container inside the rail');
expect(d.boxShadow, anyOf(isNull, isEmpty),
reason: 'no elevation inside the rail');
}
});
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(Icons.two_wheeler_rounded), 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(Icons.two_wheeler_rounded);
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(Icons.two_wheeler_rounded), findsOneWidget);
await pump(tester, route(5, 0, 0, current: false)); // all done
expect(find.byIcon(Icons.two_wheeler_rounded), 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);
});
}

View File

@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
/// The Trip 1 / 2 / 3 bar is a **pinned** sliver on Home: it scrolls up with the
/// page, then holds at the top while the stops pass underneath, so switching
/// trips never costs a scroll back up.
///
/// Two things here are worth a test.
///
/// **The extent is computed, and computed height is the classic way to clip a
/// control.** A pinned sliver must declare a fixed height, and [TripTabs]
/// deliberately has none — its own comment records that a hard `54.h` overflowed
/// by a few pixels at large font settings. `extentFor` therefore derives the
/// height from the text scale and over-estimates on purpose. If that estimate is
/// ever short, the tabs are clipped inside the header, silently. So it is
/// measured here from 1.0× to 2.0×.
///
/// **Pinning itself.** That the bar holds position while content scrolls under
/// it is the entire point of the change, and a one-word edit (`pinned: false`)
/// undoes it invisibly.
void main() {
// The view is forced to 390x844 logical below, so gestures must aim inside
// that box — x=400 would land outside the viewport and hit nothing.
const inViewport = Offset(195, 500);
const aboveKey = ValueKey('above');
const belowKey = ValueKey('below');
final trips = [
Trip.fromStops(id: 't1', stops: [
{'orderid': 'A', 'type': 'pickup', 'pickupcustomer': 'Sri Balaji'},
]),
Trip.fromStops(id: 't2', stops: [
{'orderid': 'B', 'type': 'delivery', 'pickupcustomer': 'Anand'},
]),
];
/// Pumps the real delegate inside a scroll view, at a given text scale, and
/// hands back the extent it reserved.
Future<double> pump(
WidgetTester tester, {
double textScale = 1.0,
ValueChanged<int>? onSelect,
}) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
double? captured;
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context)
.copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: Scaffold(
body: Builder(
builder: (context) {
captured = TripTabsHeader.extentFor(context);
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(
key: aboveKey,
height: 300,
color: Colors.amber,
),
),
SliverPersistentHeader(
pinned: true,
delegate: TripTabsHeader(
extent: captured!,
trips: trips,
selectedIndex: 1,
onSelect: onSelect ?? (_) {},
),
),
SliverToBoxAdapter(
child: Container(
key: belowKey,
height: 2000,
color: Colors.grey,
),
),
],
);
},
),
),
),
),
);
await tester.pumpAndSettle();
return captured!;
}
group('reserved extent fits the tabs', () {
// 2.0 is beyond Android's slider but within what a determined user can set,
// and it is the case the original 54.h bug was reported against.
for (final scale in [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
testWidgets('at ${scale}x text scale', (tester) async {
final extent = await pump(tester, textScale: scale);
expect(tester.takeException(), isNull,
reason: 'the tabs must not overflow the reserved extent');
final tabs = tester.getSize(find.byType(TripTabs));
expect(tabs.height, lessThanOrEqualTo(extent + 0.01),
reason: 'tabs are ${tabs.height} tall but only $extent was '
'reserved — the bar would be clipped');
// Over-reserving is the safe direction, but not by a wasteful amount.
expect(extent - tabs.height, lessThan(14),
reason: 'reserving $extent for ${tabs.height} of content is '
'more dead space than the safety margin intends');
// All three slots are still rendered and readable at this scale.
for (final label in ['Trip 1', 'Trip 2', 'Trip 3']) {
expect(find.text(label), findsOneWidget);
}
});
}
});
group('pinning', () {
testWidgets('the bar holds at the top while content scrolls under it',
(tester) async {
await pump(tester);
final atRest = tester.getRect(find.byType(TripTabs));
// It starts below the 300pt block above it, not stuck to the top.
expect(atRest.top, greaterThan(200));
await tester.dragFrom(inViewport, const Offset(0, -600));
await tester.pumpAndSettle();
final pinned = tester.getRect(find.byType(TripTabs));
expect(pinned.top, lessThan(atRest.top),
reason: 'it should travel up with the page first');
expect(pinned.top, lessThan(40),
reason: 'and then hold near the top of the viewport');
// Scrolling further does not move it again.
await tester.dragFrom(inViewport, const Offset(0, -600));
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(TripTabs)), pinned);
});
testWidgets('what sits above it does scroll away', (tester) async {
await pump(tester);
expect(find.byKey(aboveKey), findsOneWidget);
await tester.dragFrom(inViewport, const Offset(0, -900));
await tester.pumpAndSettle();
// The summary / shift / metrics block is meant to leave; only the control
// stays. A sliver that has left the viewport and its cache is unmounted.
expect(find.byKey(aboveKey), findsNothing);
expect(find.byType(TripTabs), findsOneWidget);
});
testWidgets('a tab is still tappable once pinned', (tester) async {
var picked = -1;
await pump(tester, onSelect: (i) => picked = i);
await tester.dragFrom(inViewport, const Offset(0, -900));
await tester.pumpAndSettle();
await tester.tap(find.text('Trip 1'));
await tester.pumpAndSettle();
expect(picked, 0,
reason: 'switching trips from deep in the list is the whole point');
});
});
}

745
test/trip_test.dart Normal file
View File

@@ -0,0 +1,745 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'fixtures/demo_stops.dart';
Map<String, dynamic> stop({
required String id,
int? step,
String type = 'pickup',
int qty = 1,
double? cash,
double lat = 11.03,
double lng = 76.96,
DateTime? due,
}) =>
{
'orderid': id,
if (step != null) 'step': step,
'type': type,
'Quantity': '$qty',
if (cash != null) 'collectionamt': '$cash',
'pickuplat': '$lat',
'pickuplon': '$lng',
if (due != null) 'expected_pickup_time': due.toIso8601String(),
};
void main() {
group('RouteMetricsHelper — distance units', () {
test('shows metres below 1 km, rounded to 10', () {
expect(RouteMetricsHelper.formatDistance(450), '450 m');
expect(RouteMetricsHelper.formatDistance(447), '450 m');
expect(RouteMetricsHelper.formatDistance(83), '80 m');
expect(RouteMetricsHelper.formatDistance(999), '1000 m');
});
test('shows kilometres with one decimal at and above 1 km', () {
expect(RouteMetricsHelper.formatDistance(1000), '1.0 km');
expect(RouteMetricsHelper.formatDistance(3200), '3.2 km');
});
test('drops the decimal past 100 km', () {
expect(RouteMetricsHelper.formatDistance(104400), '104 km');
});
test('never fakes a zero', () {
expect(RouteMetricsHelper.formatDistance(null), '—');
expect(RouteMetricsHelper.formatDistance(0), '—');
expect(RouteMetricsHelper.formatDistanceAway(null), 'Distance unknown');
});
test('rounds a tiny distance up to the 10 m floor', () {
expect(RouteMetricsHelper.formatDistance(3), '10 m');
});
});
group('RouteMetricsHelper — speed and ETA', () {
test('falls back to the urban average when speed is unusable', () {
expect(RouteMetricsHelper.usableSpeedMps(null),
RouteMetricsHelper.kDefaultSpeedMps);
// Stopped at a light must not stretch the ETA to an hour.
expect(RouteMetricsHelper.usableSpeedMps(0.4),
RouteMetricsHelper.kDefaultSpeedMps);
// A GPS spike is a bad fix, not a fast rider.
expect(RouteMetricsHelper.usableSpeedMps(60),
RouteMetricsHelper.kDefaultSpeedMps);
});
test('uses a real riding speed when it is plausible', () {
expect(RouteMetricsHelper.usableSpeedMps(8.0), 8.0);
});
test('computes an arrival clock time', () {
final now = DateTime(2026, 7, 27, 16, 0);
// 1110 m at 5.55 m/s = 200 s ≈ 3 min.
final eta = RouteMetricsHelper.eta(1110, now: now)!;
expect(eta.difference(now).inSeconds, 200);
expect(RouteMetricsHelper.formatEta(1110, now: now), '4:03 PM');
});
test('formats travel time readably', () {
expect(RouteMetricsHelper.formatDuration(const Duration(minutes: 4)),
'4 min');
expect(
RouteMetricsHelper.formatDuration(
const Duration(hours: 1, minutes: 5)),
'1h 05m');
});
});
group('Trip — grouping into slots', () {
test('buckets stops into clock-aligned slot windows', () {
final trips = Trip.groupIntoTrips([
stop(id: 'a', due: DateTime(2026, 7, 27, 10, 15)),
stop(id: 'b', due: DateTime(2026, 7, 27, 12, 40)),
stop(id: 'c', due: DateTime(2026, 7, 27, 14, 5)),
]);
// 10:15 and 12:40 both fall in the 09:00–12:00 / 12:00–15:00 blocks.
expect(trips.length, 2);
expect(trips.first.slotStart, DateTime(2026, 7, 27, 9));
expect(trips.first.slotEnd, DateTime(2026, 7, 27, 12));
expect(trips.first.stopCount, 1);
expect(trips.last.stopCount, 2);
});
test('an explicit backend trip id wins over time bucketing', () {
final trips = Trip.groupIntoTrips([
{...stop(id: 'a', due: DateTime(2026, 7, 27, 10, 0)), 'tripid': 'T1'},
{...stop(id: 'b', due: DateTime(2026, 7, 27, 20, 0)), 'tripid': 'T1'},
]);
expect(trips.length, 1);
expect(trips.single.stopCount, 2);
});
test('stops with no time still land in a trip rather than vanishing', () {
final trips = Trip.groupIntoTrips([stop(id: 'a'), stop(id: 'b')]);
expect(trips.length, 1);
expect(trips.single.stopCount, 2);
expect(trips.single.slotLabel, "Today's route");
});
test('trips come back earliest slot first', () {
final trips = Trip.groupIntoTrips([
stop(id: 'late', due: DateTime(2026, 7, 27, 16, 0)),
stop(id: 'early', due: DateTime(2026, 7, 27, 10, 0)),
]);
expect(trips.first.stops.first['orderid'], 'early');
});
test('formats the slot window as a clock range', () {
final trips = Trip.groupIntoTrips(
[stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30))],
windowHours: 3,
);
expect(trips.single.slotLabel, '9:00 AM – 12:00 PM');
});
});
group('Trip — ordering', () {
test('respects the admin step sequence, never distance', () {
final ordered = Trip.sortStops([
stop(id: 'c', step: 3),
stop(id: 'a', step: 1),
stop(id: 'b', step: 2),
]);
expect([for (final s in ordered) s['orderid']], ['a', 'b', 'c']);
});
test('falls back to booked time when step is missing', () {
final ordered = Trip.sortStops([
stop(id: 'later', due: DateTime(2026, 7, 27, 12, 0)),
stop(id: 'sooner', due: DateTime(2026, 7, 27, 10, 0)),
]);
expect(ordered.first['orderid'], 'sooner');
});
test('is stable when nothing distinguishes two stops', () {
final ordered = Trip.sortStops([stop(id: 'x'), stop(id: 'y')]);
expect([for (final s in ordered) s['orderid']], ['x', 'y']);
});
});
group('Trip — duration estimates', () {
test('service time varies by stop kind', () {
final pickup = Trip.serviceTimeFor(stop(id: 'p', type: 'pickup'));
final delivery = Trip.serviceTimeFor(stop(id: 'd', type: 'delivery'));
final combined = Trip.serviceTimeFor(stop(id: 'c', type: 'combined'));
// Handing over is quickest; doing both at one door is slowest.
expect(delivery < pickup, isTrue);
expect(pickup < combined, isTrue);
});
test('cash collection adds real minutes', () {
final free = Trip.serviceTimeFor(stop(id: 'a', type: 'pickup'));
final cod = Trip.serviceTimeFor(stop(id: 'b', type: 'pickup', cash: 250));
expect(cod - free, const Duration(seconds: 90));
});
test('extra parcels add handling time', () {
final one = Trip.serviceTimeFor(stop(id: 'a', type: 'pickup', qty: 1));
final five = Trip.serviceTimeFor(stop(id: 'b', type: 'pickup', qty: 5));
expect(five > one, isTrue);
});
test('total duration is ride time plus every doorstep minute', () {
final trip = Trip.fromStops(
id: 't',
stops: [
stop(id: 'a', step: 1, lat: 11.03, lng: 76.96),
stop(id: 'b', step: 2, lat: 11.05, lng: 76.98),
],
hubLat: 11.01,
hubLng: 76.95,
);
expect(trip.totalDuration, trip.travelDuration + trip.serviceDuration);
expect(trip.serviceDuration > Duration.zero, isTrue);
expect(trip.travelDuration > Duration.zero, isTrue);
});
test('round trip includes the return leg to the hub', () {
final stops = [
stop(id: 'a', step: 1, lat: 11.05, lng: 76.98),
];
final withHub = Trip.fromStops(
id: 'h', stops: stops, hubLat: 11.01, hubLng: 76.95);
final withoutHub = Trip.fromStops(id: 'n', stops: stops);
// hub → stop → hub must be about twice the one-way leg, and strictly
// longer than a chain with no hub at all.
expect(withHub.routeMeters, greaterThan(withoutHub.routeMeters));
});
test('average stop duration divides service time across stops', () {
final trip = Trip.fromStops(
id: 't',
stops: [
stop(id: 'a', step: 1, type: 'pickup'),
stop(id: 'b', step: 2, type: 'pickup'),
],
);
expect(trip.averageStopDuration.inSeconds,
trip.serviceDuration.inSeconds ~/ 2);
});
test('rounds estimates up to 5 minutes so they read as estimates', () {
expect(roundTripDuration(const Duration(minutes: 37)),
const Duration(minutes: 40));
expect(roundTripDuration(const Duration(minutes: 1)),
const Duration(minutes: 5));
expect(formatTripDuration(const Duration(minutes: 100)), '1h 40m');
});
});
group('Trip — totals', () {
test('splits deliver and collect parcels and sums cash', () {
final trip = Trip.fromStops(
id: 't',
stops: [
stop(id: 'a', step: 1, type: 'delivery', qty: 2),
stop(id: 'b', step: 2, type: 'pickup', qty: 3, cash: 250),
],
);
expect(trip.deliverParcels, 2);
expect(trip.collectParcels, 3);
expect(trip.cashToCollect, 250);
expect(trip.orderIds, ['a', 'b']);
});
test('a combined stop counts on both sides', () {
final combined = {
...stop(id: 'c', step: 1),
'type': 'combined',
'deliveryqty': '1',
'pickupqty': '2',
};
final trip = Trip.fromStops(id: 't', stops: [combined]);
expect(stopKindOf(combined), StopKind.combined);
expect(trip.deliverParcels, 1);
expect(trip.collectParcels, 2);
expect(trip.totalParcels, 3);
});
});
group('StopKind — three modes', () {
test('infers combined from a payload carrying both quantities', () {
final s = {'deliveryqty': '1', 'pickupqty': '2'};
expect(stopKindOf(s), StopKind.combined);
});
test('combined runs the delivery leg first', () {
expect(StopKind.combined.legs, [StopLeg.delivery, StopLeg.pickup]);
expect(StopKind.pickup.legs, [StopLeg.pickup]);
expect(StopKind.delivery.legs, [StopLeg.delivery]);
});
test('isDelivery stays narrow so combined keeps its payment path', () {
// A combined stop still has a pickup leg that can carry COD, so it must
// NOT be treated as a prepaid delivery-only stop.
expect(StopKind.combined.isDelivery, isFalse);
expect(StopKind.combined.hasDelivery, isTrue);
expect(StopKind.combined.hasPickup, isTrue);
});
test('labels and accents differ per mode', () {
expect(StopKind.combined.badgeLabel, 'PICKUP & DELIVERY');
expect(StopKind.combined.startCtaLabel, 'Start Combined Stop');
expect(StopKind.pickup.accent == StopKind.delivery.accent, isFalse);
expect(StopKind.combined.accent == StopKind.pickup.accent, isFalse);
});
test('survives a wire round trip', () {
for (final k in StopKind.values) {
expect(stopKindFromWire(k.wireName), k);
}
expect(stopKindFromWire('nonsense'), StopKind.pickup);
});
});
group('StopState — per-stop lifecycle', () {
final s1 = stop(id: 'a', step: 1);
final s2 = stop(id: 'b', step: 2);
test('local accept/reject wins over a stale server status', () {
// The optimistic write must not be bounced back to pending by a slow
// refetch that still says "new".
expect(
stopStateOf(s1, acceptedIds: {'a'}, rejectedIds: const {}),
StopState.accepted,
);
expect(
stopStateOf(s2, acceptedIds: const {}, rejectedIds: {'b'}),
StopState.rejected,
);
});
test('reads the backend status when there is no local decision', () {
expect(
stopStateOf({...s1, 'orderstatus': 'picked'},
acceptedIds: const {}, rejectedIds: const {}),
StopState.done,
);
expect(
stopStateOf({...s1, 'orderstatus': 'ARRIVED'},
acceptedIds: const {}, rejectedIds: const {}),
StopState.active,
);
expect(
stopStateOf({...s1, 'orderstatus': 'skipped'},
acceptedIds: const {}, rejectedIds: const {}),
StopState.skipped,
);
expect(
stopStateOf(s1, acceptedIds: const {}, rejectedIds: const {}),
StopState.pending,
);
});
test('classifies states for the UI', () {
expect(StopState.done.isResolved, isTrue);
// Rejected is resolved: the rider has nothing left to do with it, so it
// must not strand the trip below 100%.
expect(StopState.rejected.isResolved, isTrue);
expect(StopState.accepted.isResolved, isFalse);
expect(StopState.accepted.isCommitted, isTrue);
expect(StopState.pending.needsDecision, isTrue);
});
});
group('Trip — completion and partial accepts', () {
final stops = [
stop(id: 'a', step: 1),
stop(id: 'b', step: 2),
stop(id: 'c', step: 3),
stop(id: 'd', step: 4),
];
final trip = Trip.fromStops(id: 't', stops: stops);
test('a fresh trip is 0% and all stops need a decision', () {
expect(
trip.completionPercent(acceptedIds: const {}, rejectedIds: const {}),
0,
);
expect(
trip.pendingOrderIds(acceptedIds: const {}, rejectedIds: const {}),
['a', 'b', 'c', 'd'],
);
});
test('accepting stops does NOT advance completion — work does', () {
// Accepting is a commitment, not progress. If accepting moved the ring
// the rider would see 100% before riding a metre.
expect(
trip.completionPercent(
acceptedIds: {'a', 'b', 'c', 'd'}, rejectedIds: const {}),
0,
);
});
test('the rider can take 1, 2 and 4 and decline 3', () {
final accepted = {'a', 'b', 'd'};
const rejected = {'c'};
expect(
trip.pendingOrderIds(acceptedIds: accepted, rejectedIds: rejected),
isEmpty,
);
expect(
trip.hasPendingStops(acceptedIds: accepted, rejectedIds: rejected),
isFalse,
);
// The rejected stop counts as resolved → 1 of 4 = 25%.
expect(
trip.completionPercent(acceptedIds: accepted, rejectedIds: rejected),
25,
);
});
test('accept-all only targets stops still awaiting a decision', () {
expect(
trip.pendingOrderIds(acceptedIds: {'a'}, rejectedIds: {'b'}),
['c', 'd'],
);
});
test('completed work fills the ring', () {
final worked = Trip.fromStops(id: 't', stops: [
{...stops[0], 'orderstatus': 'picked'},
{...stops[1], 'orderstatus': 'picked'},
stops[2],
stops[3],
]);
expect(
worked.completionPercent(
acceptedIds: const {}, rejectedIds: const {}),
50,
);
expect(
worked.isFinished(acceptedIds: const {}, rejectedIds: const {}),
isFalse,
);
});
test('a trip finishes when every stop is done or rejected', () {
final worked = Trip.fromStops(id: 't', stops: [
{...stops[0], 'orderstatus': 'picked'},
{...stops[1], 'orderstatus': 'picked'},
{...stops[2], 'orderstatus': 'picked'},
stops[3],
]);
expect(
worked.completionPercent(acceptedIds: const {}, rejectedIds: {'d'}),
100,
);
expect(
worked.isFinished(acceptedIds: const {}, rejectedIds: {'d'}),
isTrue,
);
});
test('an empty trip is never reported as finished', () {
final empty = Trip.fromStops(id: 'e', stops: const []);
expect(empty.isFinished(acceptedIds: const {}, rejectedIds: const {}),
isFalse);
expect(
empty.completionPercent(
acceptedIds: const {}, rejectedIds: const {}),
0);
});
test('tab labels are 1-based', () {
expect(Trip.tabLabel(0), 'Trip 1');
expect(Trip.tabLabel(2), 'Trip 3');
});
});
group('StopState — decision precedence', () {
final s = stop(id: 'a', step: 1);
test('a local un-reject beats a stale server "rejected"', () {
// The rider tapped "Accept instead"; the next poll still says rejected.
// The local decision is strictly newer and must win, or the undo would
// silently bounce back.
expect(
stopStateOf({...s, 'orderstatus': 'rejected'},
acceptedIds: {'a'}, rejectedIds: const {}),
StopState.accepted,
);
});
test('a local reject beats a stale server "accepted"', () {
expect(
stopStateOf({...s, 'orderstatus': 'accepted'},
acceptedIds: const {}, rejectedIds: {'a'}),
StopState.rejected,
);
});
test('collected work cannot be un-decided locally', () {
// You cannot un-collect a parcel. The terminal server state wins over
// any local tap.
expect(
stopStateOf({...s, 'orderstatus': 'picked'},
acceptedIds: const {}, rejectedIds: {'a'}),
StopState.done,
);
expect(
stopStateOf({...s, 'orderstatus': 'cancelled'},
acceptedIds: {'a'}, rejectedIds: const {}),
StopState.done,
);
});
test('a stop being worked right now outranks a local decision', () {
expect(
stopStateOf({...s, 'orderstatus': 'arrived'},
acceptedIds: const {}, rejectedIds: {'a'}),
StopState.active,
);
});
test('falls back to the server view with no local decision', () {
expect(
stopStateOf({...s, 'orderstatus': 'rejected'},
acceptedIds: const {}, rejectedIds: const {}),
StopState.rejected,
);
expect(
stopStateOf({...s, 'orderstatus': 'accepted'},
acceptedIds: const {}, rejectedIds: const {}),
StopState.accepted,
);
});
});
group('Demo trips fixture', () {
test('groups into three trips covering every stop type', () {
final trips = Trip.groupIntoTrips(getDemoTripStops());
expect(trips.length, 3);
final kinds = {
for (final t in trips)
for (final s in t.stops) stopKindOf(s),
};
expect(kinds, containsAll(StopKind.values));
});
test('trip 1 covers every state the card can render', () {
final trips = Trip.groupIntoTrips(getDemoTripStops());
final states = trips.first
.states(acceptedIds: const {}, rejectedIds: const {});
// done + active exercise the locked rows; skipped the amber state;
// pending the accept/reject buttons.
expect(states, contains(StopState.done));
expect(states, contains(StopState.active));
expect(states, contains(StopState.skipped));
expect(states, contains(StopState.pending));
// Part-worked, so the ring is neither empty nor full.
final percent = trips.first
.completionPercent(acceptedIds: const {}, rejectedIds: const {});
expect(percent, greaterThan(0));
expect(percent, lessThan(100));
});
test('every stop type appears in more than one trip', () {
final trips = Trip.groupIntoTrips(getDemoTripStops());
for (final kind in StopKind.values) {
final tripsWithKind = trips
.where((t) => t.stops.any((s) => stopKindOf(s) == kind))
.length;
expect(tripsWithKind, greaterThan(1), reason: 'only $tripsWithKind trip(s) have $kind');
}
});
test('deliveries and combined stops are well represented', () {
final stops = getDemoTripStops();
int countOf(StopKind k) =>
stops.where((s) => stopKindOf(s) == k).length;
// The card's delivery and combined branches were the thinnest on data,
// so the fixture deliberately over-weights them.
expect(countOf(StopKind.delivery), greaterThanOrEqualTo(6));
expect(countOf(StopKind.combined), greaterThanOrEqualTo(6));
expect(countOf(StopKind.pickup), greaterThanOrEqualTo(3));
});
test('a finished DELIVERY exists, not just a finished pickup', () {
final done = getDemoTripStops().where((s) =>
stopStateOf(s, acceptedIds: const {}, rejectedIds: const {}) ==
StopState.done);
expect(done.any((s) => stopKindOf(s) == StopKind.delivery), isTrue);
expect(done.any((s) => stopKindOf(s) == StopKind.pickup), isTrue);
});
test('a combined stop carries cash on its pickup leg', () {
final combinedWithCash = getDemoTripStops().where((s) =>
stopKindOf(s) == StopKind.combined && stopCollectionAmount(s) > 0);
expect(combinedWithCash, isNotEmpty);
});
test('multi-parcel deliveries exist for the service-time estimate', () {
final multi = getDemoTripStops().where((s) =>
stopKindOf(s) == StopKind.delivery && deliveryParcelCount(s) > 2);
expect(multi, isNotEmpty);
});
test('trips 2 and 3 are entirely undecided', () {
final trips = Trip.groupIntoTrips(getDemoTripStops());
for (final t in trips.skip(1)) {
expect(
t.pendingOrderIds(acceptedIds: const {}, rejectedIds: const {})
.length,
t.stopCount,
);
}
});
test('demo stops carry the fields the card renders', () {
for (final s in getDemoTripStops()) {
expect((s['orderid'] as String).isNotEmpty, isTrue);
expect((s['pickupcustomer'] as String).isNotEmpty, isTrue);
expect((s['pickupaddress'] as String).isNotEmpty, isTrue);
expect(double.tryParse(s['pickuplat'] as String), isNotNull);
expect(DateTime.tryParse(s['expected_pickup_time'] as String),
isNotNull);
}
});
test('a combined demo stop splits deliver and collect counts', () {
final combined = getDemoTripStops()
.firstWhere((s) => stopKindOf(s) == StopKind.combined);
expect(deliveryParcelCount(combined), greaterThan(0));
expect(pickupParcelCount(combined), greaterThan(0));
});
});
group('Trip tabs — always three slots', () {
test('a one-trip day still fills three tab slots', () {
final trips = Trip.groupIntoTrips([
stop(id: 'a', due: DateTime(2026, 7, 27, 10, 0)),
]);
expect(trips.length, 1);
// Trip 2 and Trip 3 exist as tabs; indices past the end are legitimate
// "not assigned yet" slots, not errors.
const maxInline = 3;
final tabCount = trips.length > maxInline ? trips.length : maxInline;
expect(tabCount, 3);
expect(2.clamp(0, tabCount - 1), 2);
});
test('an unusual day folds into three trips without losing a stop', () {
// Bucketing is time-based, so a stop booked well outside the others — or
// simply a long day — used to produce a fourth, fifth or eighth tab.
// "Trip 8" is not a thing the hub assigns.
final stops = [
for (var h = 0; h < 24; h += 3)
stop(id: 'h$h', due: DateTime(2026, 7, 27, h, 30)),
];
final trips = Trip.groupIntoTrips(stops);
expect(trips.length, Trip.maxTripsPerDay,
reason: 'a rider is assigned three slots, not eight');
// The part that matters. Truncating the list would be the one-line fix and
// it would silently hide real work: these are accepted bookings, and a
// stop the rider cannot see is a stop he cannot deliver.
final carried = trips.expand((t) => t.stops).length;
expect(carried, stops.length,
reason: 'every stop must survive the merge — $carried of '
'${stops.length} came through');
final ids = trips
.expand((t) => t.stops)
.map((s) => s['orderid'])
.toSet();
expect(ids.length, stops.length, reason: 'and none may be duplicated');
// The tail lands in the last trip, whose window widens to cover what it
// now holds, so the slot label stays honest.
expect(trips.last.stops.length, greaterThan(1));
expect(trips.last.slotStart!.isBefore(trips.last.slotEnd!), isTrue);
});
test('a normal day is left exactly as it is', () {
final trips = Trip.groupIntoTrips([
stop(id: 'a', due: DateTime(2026, 7, 27, 9, 30)),
stop(id: 'b', due: DateTime(2026, 7, 27, 13, 30)),
]);
expect(trips.length, 2, reason: 'the cap only bites past three');
});
});
group('Address compression', () {
test('drops the tail every stop on the trip shares', () {
const a = '5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041';
const b = '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu, 641012';
// Only the pincode differs, so "Coimbatore, Tamil Nadu" is redundant.
final tail = commonAddressTail(const [a, b]);
expect(tail, isEmpty); // pincodes differ → nothing shared at the very end
});
test('strips a fully shared tail', () {
const a = '5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu';
const b = '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu';
final tail = commonAddressTail(const [a, b]);
expect(tail, ['Coimbatore', 'Tamil Nadu']);
expect(stripAddressTail(a, tail), '5/2, North Avenue, Vadavalli');
expect(stripAddressTail(b, tail), '78, Cross Cut Road, Gandhipuram');
});
test('is a no-op when nothing is shared', () {
const a = '5/2, North Avenue, Vadavalli';
const b = '78, Cross Cut Road, Chennai';
expect(commonAddressTail(const [a, b]), isEmpty);
expect(stripAddressTail(a, const []), a);
});
test('is a no-op on a one-stop trip', () {
expect(commonAddressTail(const ['5/2, North Avenue, Coimbatore']),
isEmpty);
});
test('never strips an address down to nothing', () {
// Two identical addresses share everything; one component must survive.
const a = 'Coimbatore';
final tail = commonAddressTail(const [a, a]);
expect(stripAddressTail(a, tail).isNotEmpty, isTrue);
});
test('ignores case when matching the tail', () {
const a = '5/2, North Avenue, COIMBATORE';
const b = '78, Cross Cut Road, coimbatore';
final tail = commonAddressTail(const [a, b]);
expect(tail.length, 1);
expect(stripAddressTail(a, tail), '5/2, North Avenue');
});
test('shortens the real demo trip addresses', () {
final trips = Trip.groupIntoTrips(getDemoTripStops());
for (final t in trips) {
final tail = t.sharedAddressTail;
// Every demo address ends in "Coimbatore".
expect(tail, ['Coimbatore']);
for (final stop in t.stops) {
final short = t.shortAddress(stop, tail);
expect(short.contains('Coimbatore'), isFalse);
expect(short.isNotEmpty, isTrue);
}
}
});
});
}