Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
330
test/two_lines_test.dart
Normal file
330
test/two_lines_test.dart
Normal file
@@ -0,0 +1,330 @@
|
||||
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/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ONE APP, TWO DAYS
|
||||
///
|
||||
/// The whole merge rests on one promise, in both directions:
|
||||
///
|
||||
/// • A rider whose login carries a **meal tenant id** gets the meal process —
|
||||
/// kitchen loading, deliveries, no money, no return leg.
|
||||
/// • A rider **without** one gets the parcel app exactly as it was: hub to hub,
|
||||
/// bookings, cash, per-stop decisions.
|
||||
///
|
||||
/// The second half of that is the one that can break silently. Nobody files a
|
||||
/// bug saying "the meal build looks fine"; a parcel rider losing his cash screen
|
||||
/// or gaining a kitchen heading is found at a customer's door. So every meal
|
||||
/// assertion below has a parcel one beside it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> stop({
|
||||
required String id,
|
||||
String status = 'ASSIGNED',
|
||||
String kitchen = '',
|
||||
num cash = 0,
|
||||
}) => {
|
||||
'orderid': id,
|
||||
'step': int.parse(id),
|
||||
'pickupcustomer': 'Customer $id',
|
||||
'pickupaddress': '$id Main Road, RS Puram',
|
||||
'orderstatus': status,
|
||||
if (kitchen.isNotEmpty) 'kitchenname': kitchen,
|
||||
if (kitchen.isNotEmpty) 'baglabel': 'BAG-$id',
|
||||
if (cash > 0) 'collectionamt': cash,
|
||||
'pickuplat': 13.08,
|
||||
'pickuplon': 80.21,
|
||||
};
|
||||
|
||||
Future<void> pumpRoute(
|
||||
WidgetTester tester, {
|
||||
required List<Map<String, dynamic>> stops,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1080, 2600);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final trip = Trip.fromStops(id: 't', stops: stops);
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(
|
||||
trip: trip,
|
||||
onToggleSelect: (_) {},
|
||||
onViewRoute: () {},
|
||||
).buildStops(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// THE MEAL LOGIN — what a tenant id buys
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('a rider who logs in on a meal tenant', () {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.meals));
|
||||
|
||||
testWidgets('starts at a kitchen, and never comes back to a hub', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRoute(
|
||||
tester,
|
||||
stops: [
|
||||
stop(id: '1', kitchen: 'Kitchen 1'),
|
||||
stop(id: '2', kitchen: 'Kitchen 1'),
|
||||
],
|
||||
);
|
||||
|
||||
// ── Ported to the timeline ──
|
||||
//
|
||||
// `START · KITCHEN` was the hub row's label, and the hub row is gone with
|
||||
// the cards: the run opens on one line-agnostic head instead. What that
|
||||
// row was really holding is the half below, which is unchanged — a meal
|
||||
// run has no return leg.
|
||||
expect(find.text('TODAY’S RUN'), findsOneWidget);
|
||||
// His day ends at the last subscriber's door. A return leg to a warehouse
|
||||
// he has never seen is an un-completable step at the end of a route he
|
||||
// has in fact finished.
|
||||
expect(find.text('RETURN · HUB'), findsNothing);
|
||||
expect(find.text('START · HUB'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sees his route grouped under the kitchen it comes from', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRoute(
|
||||
tester,
|
||||
stops: [
|
||||
stop(id: '1', kitchen: 'Kitchen 1'),
|
||||
stop(id: '2', kitchen: 'Kitchen 2'),
|
||||
],
|
||||
);
|
||||
|
||||
// Two kitchens, two headings — the shape of a meal day, which a flat list
|
||||
// hides until he opens every card.
|
||||
expect(find.text('Kitchen 1'), findsOneWidget);
|
||||
expect(find.text('Kitchen 2'), findsOneWidget);
|
||||
// And the bag, which is what he checks against the box in his hand.
|
||||
// The label the kitchen printed, as [BagManifest] hands it over — the
|
||||
// `Bag ` prefix belonged to the card's chip and went with it, which
|
||||
// stopped a printed `BAG-1` reading as `Bag BAG-1`.
|
||||
expect(find.text('BAG-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('works deliveries, not bookings', () {
|
||||
final p = ServiceProfile.active;
|
||||
expect(p.workTabLabel, 'Deliveries');
|
||||
expect(p.jobNounPlural, 'deliveries');
|
||||
expect(p.completionVerb, 'Delivered');
|
||||
});
|
||||
|
||||
test('hands a subscriber food — he does not collect from one', () {
|
||||
// Every stop after the kitchen is a drop, whether or not the payload
|
||||
// bothered to label it.
|
||||
expect(stopKindOf(stop(id: '1')).isDelivery, isTrue);
|
||||
});
|
||||
|
||||
test('is never asked for money, however the payload is priced', () {
|
||||
// Subscribers pay the client monthly. A rupee figure at the door is a
|
||||
// question the rider cannot answer and must not be asked.
|
||||
expect(stopCollectionAmount(stop(id: '1', cash: 450)), 0);
|
||||
expect(ServiceProfile.active.collectsCash, isFalse);
|
||||
});
|
||||
|
||||
test('takes the whole assignment, not stop by stop', () {
|
||||
// He cannot decline one subscriber's lunch.
|
||||
expect(ServiceProfile.active.acceptsPerStop, isFalse);
|
||||
});
|
||||
|
||||
test('photographs the crate once, at the counter', () {
|
||||
expect(ServiceProfile.active.bulkLoadProof, isTrue);
|
||||
});
|
||||
|
||||
test('keeps the order on Home until it is physically in his box', () {
|
||||
// Accepting changes nothing physical — the food is still in a kitchen he
|
||||
// has not reached.
|
||||
expect(ServiceProfile.active.handsOffAtCollection, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// THE PARCEL LOGIN — nothing moved
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('a rider who logs in without a meal tenant', () {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
testWidgets('gets the hub-to-hub route he has always had', (tester) async {
|
||||
await pumpRoute(
|
||||
tester,
|
||||
stops: [
|
||||
stop(id: '1'),
|
||||
stop(id: '2'),
|
||||
],
|
||||
);
|
||||
|
||||
// The parcel day is still a loop: he leaves the hub empty, fills up at
|
||||
// customer doors and comes back. The head is shared with the meal run;
|
||||
// the closing leg is what tells the two apart.
|
||||
expect(find.text('TODAY’S RUN'), findsOneWidget);
|
||||
expect(find.text('RETURN · HUB'), findsOneWidget);
|
||||
expect(find.text('START · KITCHEN'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('gets no kitchen sections and no meal chrome', (tester) async {
|
||||
// Nothing from the meal run may leak onto a parcel route. The *source
|
||||
// pill* is data-driven and stays if a parcel payload ever names one —
|
||||
// see [StopSourceLine] — but the grouping, its counts and its bulk
|
||||
// collect are meal-flow structure and must not appear.
|
||||
await pumpRoute(
|
||||
tester,
|
||||
stops: [stop(id: '1', kitchen: 'Kitchen 1')],
|
||||
);
|
||||
expect(find.text('1 meal · 1 to accept'), findsNothing);
|
||||
expect(find.textContaining('ready to collect'), findsNothing);
|
||||
expect(find.text('MEAL'), findsNothing);
|
||||
// The stop is drawn — it is just drawn flat. A parcel route has no
|
||||
// counter to group under, so the timeline is one node per stop and the
|
||||
// header *is* the stop. `PICKUP` was the card's type mark; the type is
|
||||
// progressive disclosure in the detail sheet now.
|
||||
expect(find.text('Customer 1'), findsOneWidget);
|
||||
expect(find.text('Kitchen 1'), findsNothing);
|
||||
// "Collect 1" on the meta line is the parcel card's own count of what he
|
||||
// is picking up at this door, and stays. The heading assertions above are
|
||||
// what tell the two apart.
|
||||
});
|
||||
|
||||
test('still works bookings', () {
|
||||
final p = ServiceProfile.active;
|
||||
expect(p.workTabLabel, 'Bookings');
|
||||
expect(p.jobNounPlural, 'bookings');
|
||||
expect(p.completionVerb, 'Picked up');
|
||||
});
|
||||
|
||||
test('still collects the cash the stop is priced at', () {
|
||||
// The failure this app cannot afford: a rider at a door with no way to
|
||||
// record the money he is holding.
|
||||
expect(stopCollectionAmount(stop(id: '1', cash: 450)), 450);
|
||||
expect(ServiceProfile.active.collectsCash, isTrue);
|
||||
});
|
||||
|
||||
test('still decides stop by stop', () {
|
||||
expect(ServiceProfile.active.acceptsPerStop, isTrue);
|
||||
});
|
||||
|
||||
test('an unlabelled stop is still a pickup', () {
|
||||
expect(stopKindOf(stop(id: '1')), StopKind.pickup);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// THE SWITCH ITSELF
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('what decides which day he gets', () {
|
||||
Future<ServiceProfile> loginWith(Map<String, Object> prefs) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
final p = await resolveServiceProfile();
|
||||
ServiceProfile.setActive(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
test('a recognised meal tenant name puts him on the meal line', () async {
|
||||
final p = await loginWith({'tenantname': 'DailyGrubs'});
|
||||
expect(p.line, ServiceLine.milkMan);
|
||||
});
|
||||
|
||||
test(
|
||||
'no tenant, an unknown tenant, or a blank one is a parcel day',
|
||||
() async {
|
||||
expect((await loginWith({})).line, ServiceLine.parcel);
|
||||
expect((await loginWith({'tenantid': 4242})).line, ServiceLine.parcel);
|
||||
expect((await loginWith({'tenantname': ''})).line, ServiceLine.parcel);
|
||||
},
|
||||
);
|
||||
|
||||
test('the fallback direction is the safe one', () async {
|
||||
// Wrong-way round, a live parcel rider loses the screen that records a
|
||||
// customer's cash. This way, a meal rider sees a payment prompt he can
|
||||
// dismiss.
|
||||
final p = await loginWith({'tenantname': 'something-nobody-configured'});
|
||||
expect(p.collectsCash, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── The round does not end at a warehouse ──
|
||||
//
|
||||
// A logistics day is a loop: leave the hub empty, fill up at customer doors,
|
||||
// return with the consignments — so "then hub" is the true last leg and the
|
||||
// rider is not finished until he is there. A meal rider finishes at the last
|
||||
// subscriber's door and goes home. Telling him his round ends at a building he
|
||||
// has never visited is the same fiction the RETURN · HUB row was removed from
|
||||
// Home for, and it survived on the progress rail.
|
||||
group("the progress rail speaks each line's own language", () {
|
||||
Future<String> subtitle(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1080, 1200);
|
||||
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: TripProgressRail(
|
||||
progress: const [
|
||||
StopProgress.done,
|
||||
StopProgress.current,
|
||||
StopProgress.pending,
|
||||
],
|
||||
slotLabel: 'Trip 1',
|
||||
remaining: const Duration(minutes: 25),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
return tester
|
||||
.widgetList<Text>(find.byType(Text))
|
||||
.map((t) => t.data ?? '')
|
||||
.firstWhere((d) => d.contains('left'), orElse: () => '');
|
||||
}
|
||||
|
||||
testWidgets('a meal round just ends', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
final line = await subtitle(tester);
|
||||
expect(line, contains('left'));
|
||||
expect(
|
||||
line,
|
||||
isNot(contains('hub')),
|
||||
reason: 'he goes home from the last door, not to a warehouse',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a parcel day is a loop, and says so', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final line = await subtitle(tester);
|
||||
expect(line, contains('then hub'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user