first commit
This commit is contained in:
65
test/accepted_pill_test.dart
Normal file
65
test/accepted_pill_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
159
test/active_pickup_banner_test.dart
Normal file
159
test/active_pickup_banner_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
199
test/activity_list_test.dart
Normal file
199
test/activity_list_test.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
47
test/activity_refresh_test.dart
Normal file
47
test/activity_refresh_test.dart
Normal 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]);
|
||||
});
|
||||
}
|
||||
105
test/app_bar_glass_test.dart
Normal file
105
test/app_bar_glass_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
122
test/assignment_lookup_test.dart
Normal file
122
test/assignment_lookup_test.dart
Normal 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
353
test/auth_design_test.dart
Normal 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);
|
||||
82
test/bookings_trip_label_test.dart
Normal file
82
test/bookings_trip_label_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
205
test/bottom_nav_compact_test.dart
Normal file
205
test/bottom_nav_compact_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
269
test/bottom_nav_indicator_test.dart
Normal file
269
test/bottom_nav_indicator_test.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The selection pill travels between tabs instead of teleporting.
|
||||
///
|
||||
/// ── The bug ──
|
||||
///
|
||||
/// The pill was part of each tab: an `AnimatedContainer` per item whose fill
|
||||
/// went from transparent to red over 280ms. Going Account → Home therefore faded
|
||||
/// a pill out on the far right while a *second* pill faded in on the far left.
|
||||
/// Nothing ever occupied the space between them, so there was no way for it to
|
||||
/// pass over Bookings and Earnings — the highlight simply appeared at the
|
||||
/// destination.
|
||||
///
|
||||
/// There is now one pill, owned by the bar, positioned by an animated value in
|
||||
/// tab units. These tests measure where it actually is, frame by frame.
|
||||
///
|
||||
/// ── Why it is replicated rather than pumped ──
|
||||
///
|
||||
/// As in `bottom_nav_autohide_test.dart`: `BottomPage` builds four real tab
|
||||
/// pages (Get controllers, SharedPreferences, Geolocator, live fetches) and
|
||||
/// cannot be pumped headless. So this pins the *mechanism* — the same
|
||||
/// `Stack` + `PositionedDirectional(start: slot * pos)` + single interrupted
|
||||
/// `Tween`, at the same 380ms and `easeInOutCubic` — and asserts the properties
|
||||
/// the real bar has to have.
|
||||
void main() {
|
||||
const pillKey = ValueKey('pill');
|
||||
const tabs = 4;
|
||||
const barWidth = 400.0;
|
||||
const slot = barWidth / tabs;
|
||||
const duration = Duration(milliseconds: 380);
|
||||
|
||||
/// Mirrors `_BottomPageState`'s pill wiring.
|
||||
Widget bar({required ValueNotifier<int> selected, required int initial}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: barWidth,
|
||||
height: 66,
|
||||
child: _PillBar(
|
||||
key: const ValueKey('bar'),
|
||||
selected: selected,
|
||||
initial: initial,
|
||||
pillKey: pillKey,
|
||||
tabs: tabs,
|
||||
duration: duration,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double pillLeft(WidgetTester tester) =>
|
||||
tester.getTopLeft(find.byKey(pillKey)).dx -
|
||||
tester.getTopLeft(find.byKey(const ValueKey('bar'))).dx;
|
||||
|
||||
testWidgets('at rest the pill sits on the selected tab', (tester) async {
|
||||
for (final start in [0, 1, 2, 3]) {
|
||||
final selected = ValueNotifier(start);
|
||||
addTearDown(selected.dispose);
|
||||
// Tear the tree down between iterations: the bar carries a fixed key, so
|
||||
// pumping straight over it keeps the old State and `initial` — read once
|
||||
// in `initState` — would never be re-applied.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpWidget(bar(selected: selected, initial: start));
|
||||
await tester.pumpAndSettle();
|
||||
expect(pillLeft(tester), closeTo(slot * start, 0.5),
|
||||
reason: 'tab $start should be the one wearing the pill');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Account → Home crosses Earnings and Bookings on the way',
|
||||
(tester) async {
|
||||
// The failing case the rider described, measured directly.
|
||||
final selected = ValueNotifier(3);
|
||||
addTearDown(selected.dispose);
|
||||
await tester.pumpWidget(bar(selected: selected, initial: 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(pillLeft(tester), closeTo(slot * 3, 0.5));
|
||||
|
||||
selected.value = 0;
|
||||
await tester.pump();
|
||||
|
||||
// Sample the whole 380ms.
|
||||
final samples = <double>[];
|
||||
for (var elapsed = 0; elapsed < 400; elapsed += 20) {
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
samples.add(pillLeft(tester));
|
||||
}
|
||||
|
||||
// It must actually have been over Earnings (slot 2) and Bookings (slot 1)
|
||||
// at some point. A pair of cross-fading per-tab pills produces *no* sample
|
||||
// in either band — the whole point of the test.
|
||||
bool over(int tab) => samples.any(
|
||||
(x) => x > slot * tab - slot / 2 && x < slot * tab + slot / 2);
|
||||
expect(over(2), isTrue,
|
||||
reason: 'the pill never passed over Earnings; samples: $samples');
|
||||
expect(over(1), isTrue,
|
||||
reason: 'the pill never passed over Bookings; samples: $samples');
|
||||
|
||||
// And it went there directly — no backtracking, which would read as a wobble.
|
||||
for (var i = 1; i < samples.length; i++) {
|
||||
expect(samples[i], lessThanOrEqualTo(samples[i - 1] + 0.5),
|
||||
reason: 'the pill moved backwards at sample $i: $samples');
|
||||
}
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(pillLeft(tester), closeTo(0, 0.5), reason: 'it must arrive on Home');
|
||||
});
|
||||
|
||||
testWidgets('a tap mid-slide resumes from where the pill is', (tester) async {
|
||||
// Rebuilding the tween from `selected` rather than from the pill's live
|
||||
// position would snap it back to Account and re-run the entire journey.
|
||||
final selected = ValueNotifier(3);
|
||||
addTearDown(selected.dispose);
|
||||
await tester.pumpWidget(bar(selected: selected, initial: 3));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
selected.value = 0;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 190));
|
||||
final midway = pillLeft(tester);
|
||||
expect(midway, greaterThan(slot * 0.5));
|
||||
expect(midway, lessThan(slot * 2.5));
|
||||
|
||||
// Change your mind, back towards Account.
|
||||
selected.value = 2;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(pillLeft(tester), closeTo(midway, slot * 0.25),
|
||||
reason: 'it should carry on from $midway, not jump back to ${slot * 3}');
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(pillLeft(tester), closeTo(slot * 2, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('the tabs being crossed light up as it passes', (tester) async {
|
||||
// The tint is derived from the pill's distance, not from `selected`. With a
|
||||
// boolean, Home turned red on the tap while the pill was still two tabs
|
||||
// away — the destination lighting up before the thing travelling to it
|
||||
// arrives is most of what made the old version feel disconnected.
|
||||
final selected = ValueNotifier(3);
|
||||
addTearDown(selected.dispose);
|
||||
await tester.pumpWidget(bar(selected: selected, initial: 3));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Only Account is lit at rest.
|
||||
expect(_lit(tester), [0.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
selected.value = 0;
|
||||
await tester.pump();
|
||||
|
||||
var sawMiddleGlow = false;
|
||||
for (var elapsed = 0; elapsed < 400; elapsed += 20) {
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
final lit = _lit(tester);
|
||||
if (lit[1] > 0.05 || lit[2] > 0.05) sawMiddleGlow = true;
|
||||
}
|
||||
expect(sawMiddleGlow, isTrue,
|
||||
reason: 'Bookings and Earnings should warm up under the passing pill');
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(_lit(tester), [1.0, 0.0, 0.0, 0.0]);
|
||||
});
|
||||
}
|
||||
|
||||
/// The `lit` value each tab is rendering, read back off the widget tree.
|
||||
List<double> _lit(WidgetTester tester) => tester
|
||||
.widgetList<_Tab>(find.byType(_Tab))
|
||||
.map((w) => double.parse(w.lit.toStringAsFixed(3)))
|
||||
.toList();
|
||||
|
||||
class _PillBar extends StatefulWidget {
|
||||
final ValueNotifier<int> selected;
|
||||
final int initial;
|
||||
final Key pillKey;
|
||||
final int tabs;
|
||||
final Duration duration;
|
||||
|
||||
const _PillBar({
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.initial,
|
||||
required this.pillKey,
|
||||
required this.tabs,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PillBar> createState() => _PillBarState();
|
||||
}
|
||||
|
||||
class _PillBarState extends State<_PillBar> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _anim;
|
||||
late final Animation<double> _curve;
|
||||
late Tween<double> _tween;
|
||||
|
||||
double get _pos => _tween.evaluate(_curve);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_anim = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
value: 1.0,
|
||||
);
|
||||
_curve = CurvedAnimation(parent: _anim, curve: Curves.easeInOutCubic);
|
||||
_tween = Tween<double>(
|
||||
begin: widget.initial.toDouble(),
|
||||
end: widget.initial.toDouble(),
|
||||
);
|
||||
widget.selected.addListener(_onSelect);
|
||||
}
|
||||
|
||||
void _onSelect() {
|
||||
_tween = Tween<double>(begin: _pos, end: widget.selected.value.toDouble());
|
||||
_anim.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selected.removeListener(_onSelect);
|
||||
_anim.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (context, _) {
|
||||
final pos = _pos;
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final slot = box.maxWidth / widget.tabs;
|
||||
return Stack(
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
start: slot * pos,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: slot,
|
||||
child: Container(key: widget.pillKey, color: Colors.red),
|
||||
),
|
||||
Row(
|
||||
children: List.generate(widget.tabs, (i) {
|
||||
final t = (1.0 - (pos - i).abs()).clamp(0.0, 1.0);
|
||||
return Expanded(child: _Tab(lit: t * t * t));
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
final double lit;
|
||||
const _Tab({required this.lit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.expand();
|
||||
}
|
||||
162
test/card_cta_height_test.dart
Normal file
162
test/card_cta_height_test.dart
Normal 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
990
test/card_density_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
74
test/card_stripe_layout_test.dart
Normal file
74
test/card_stripe_layout_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
210
test/collect_payment_test.dart
Normal file
210
test/collect_payment_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
143
test/completed_store_test.dart
Normal file
143
test/completed_store_test.dart
Normal 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')));
|
||||
});
|
||||
}
|
||||
290
test/contrast_contract_test.dart
Normal file
290
test/contrast_contract_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
128
test/demo_bookings_test.dart
Normal file
128
test/demo_bookings_test.dart
Normal 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
234
test/done_handoff_test.dart
Normal 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
118
test/duty_sheet_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
BIN
test/failures/mode_dark_isolatedDiff.png
Normal file
BIN
test/failures/mode_dark_isolatedDiff.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
test/failures/mode_dark_maskedDiff.png
Normal file
BIN
test/failures/mode_dark_maskedDiff.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
test/failures/mode_dark_masterImage.png
Normal file
BIN
test/failures/mode_dark_masterImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
test/failures/mode_dark_testImage.png
Normal file
BIN
test/failures/mode_dark_testImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
842
test/fixtures/demo_stops.dart
vendored
Normal file
842
test/fixtures/demo_stops.dart
vendored
Normal 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
152
test/home_appbar_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
91
test/intro_screen_test.dart
Normal file
91
test/intro_screen_test.dart
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
108
test/offline_banner_test.dart
Normal file
108
test/offline_banner_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
228
test/profile_pages_test.dart
Normal file
228
test/profile_pages_test.dart
Normal 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
102
test/review_grid_test.dart
Normal 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
72
test/rider_tier_test.dart
Normal 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
313
test/route_brief_test.dart
Normal 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
161
test/route_flow_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
96
test/selection_bar_test.dart
Normal file
96
test/selection_bar_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
88
test/skipped_store_test.dart
Normal file
88
test/skipped_store_test.dart
Normal 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']);
|
||||
});
|
||||
}
|
||||
297
test/stop_card_neutral_test.dart
Normal file
297
test/stop_card_neutral_test.dart
Normal 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()}%');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
114
test/stop_compliance_test.dart
Normal file
114
test/stop_compliance_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
177
test/stop_row_alignment_test.dart
Normal file
177
test/stop_row_alignment_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
176
test/stop_verify_chrome_test.dart
Normal file
176
test/stop_verify_chrome_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
102
test/stop_verify_layout_test.dart
Normal file
102
test/stop_verify_layout_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
243
test/stop_verify_review_test.dart
Normal file
243
test/stop_verify_review_test.dart
Normal 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
162
test/stop_verify_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
105
test/text_scale_sweep_test.dart
Normal file
105
test/text_scale_sweep_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
95
test/trip_brief_layout_test.dart
Normal file
95
test/trip_brief_layout_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
79
test/trip_empty_state_test.dart
Normal file
79
test/trip_empty_state_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
169
test/trip_progress_test.dart
Normal file
169
test/trip_progress_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
183
test/trip_tabs_pinned_test.dart
Normal file
183
test/trip_tabs_pinned_test.dart
Normal 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
745
test/trip_test.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user