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>
400 lines
15 KiB
Dart
400 lines
15 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/views/Dashboard/activity/activity_page.dart';
|
|
import 'package:miler/views/Dashboard/activity/delivery_details_page.dart';
|
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
|
import 'package:miler/data/accepted_store.dart';
|
|
|
|
/// ── What a finished row says, and what it stopped saying ──
|
|
///
|
|
/// A finished stop used to carry, under its address, a filled state tag, a
|
|
/// dotted type, a copyable reference and three 17pt figures with a coloured
|
|
/// verdict under each — six marks per row on a tab whose job is to be scanned.
|
|
/// A day's work was taller than the day's work list on the Deliveries tab.
|
|
///
|
|
/// It is three lines now: who and when, where, and one verdict line that says
|
|
/// what kind of stop it was and how it went. The record — every stamp, every
|
|
/// figure, the reference, the money — is one tap under it.
|
|
///
|
|
/// [activity_list_test.dart] holds the older half of this contract: that a row
|
|
/// is not a card, that the figure columns line up across rows rather than only
|
|
/// within one, that the type is a word rather than a filled tag. This file
|
|
/// holds what the redesign added: what is on the collapsed row, that a missing
|
|
/// measurement is said rather than invented, and that money is gated on the
|
|
/// capability rather than on the line of work.
|
|
void main() {
|
|
String day(DateTime t) =>
|
|
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
|
|
'${t.day.toString().padLeft(2, '0')}';
|
|
|
|
final now = DateTime.now();
|
|
|
|
Map<String, dynamic> stop(
|
|
String id,
|
|
String name, {
|
|
required String status,
|
|
String type = 'delivery',
|
|
int minsAgo = 20,
|
|
double cash = 0,
|
|
double? collected,
|
|
Map<String, dynamic>? compliance,
|
|
}) {
|
|
final at = now.subtract(Duration(minutes: minsAgo));
|
|
return {
|
|
'orderid': id,
|
|
'tripid': 'T1',
|
|
'type': type,
|
|
'pickupcustomer': name,
|
|
'pickupaddress': '12, SNS Colony, Coimbatore',
|
|
'orderstatus': status,
|
|
'completedday': day(now),
|
|
'completedat': at.toIso8601String(),
|
|
'startedat': at.subtract(const Duration(minutes: 14)).toIso8601String(),
|
|
'arrivedat': at.subtract(const Duration(minutes: 3)).toIso8601String(),
|
|
if (cash > 0) 'collectionamt': '$cash',
|
|
if (compliance != null) 'compliance': compliance,
|
|
'proof': {
|
|
if (collected != null) 'amountcollected': collected,
|
|
'weight': '2.4',
|
|
'collected': 2,
|
|
},
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> parked(String id, String name, String reason) => {
|
|
'orderid': id,
|
|
'tripid': 'T1',
|
|
'type': 'delivery',
|
|
'pickupcustomer': name,
|
|
'pickupaddress': '19, Thadagam Road, Coimbatore',
|
|
'orderstatus': 'skipped',
|
|
'skipreason': reason,
|
|
'skippedday': day(now),
|
|
'skippedat': now.subtract(const Duration(minutes: 40)).toIso8601String(),
|
|
};
|
|
|
|
Future<void> pump(
|
|
WidgetTester tester, {
|
|
List<Map<String, dynamic>> done = const [],
|
|
List<Map<String, dynamic>> skipped = const [],
|
|
ServiceProfile profile = ServiceProfile.parcel,
|
|
bool settle = true,
|
|
}) async {
|
|
ServiceProfile.setActive(profile);
|
|
SharedPreferences.setMockInitialValues({'userId': 1});
|
|
await debugSeedStore('completed_bookings', jsonEncode(done));
|
|
await debugSeedStore('skipped_bookings', jsonEncode(skipped));
|
|
|
|
tester.view.physicalSize = const Size(390 * 3, 1600 * 3);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
await tester.pumpWidget(
|
|
ScreenUtilInit(
|
|
designSize: const Size(390, 844),
|
|
builder: (_, _) => const MaterialApp(home: ActivityPage()),
|
|
),
|
|
);
|
|
if (settle) {
|
|
await tester.pumpAndSettle(const Duration(seconds: 2));
|
|
} else {
|
|
// The empty state runs a looping Lottie, so nothing ever settles.
|
|
await tester.pump();
|
|
for (var i = 0; i < 20; i++) {
|
|
await tester.pump(const Duration(milliseconds: 80));
|
|
}
|
|
}
|
|
}
|
|
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
final onTime = {'ontime': true, 'actualkm': 3.4, 'plannedkm': 3.1};
|
|
final late7 = {
|
|
'ontime': false,
|
|
'latebyseconds': 420,
|
|
'actualkm': 6.2,
|
|
'plannedkm': 3.0,
|
|
};
|
|
|
|
// ── History is scanned, not read ──
|
|
//
|
|
// The collapsed row carried a name, a two-line address and a verdict sentence
|
|
// (`Delivery · On time · +3.2 km over · ₹340`) — four to six lines per stop,
|
|
// on a page whose question is "what did I do today". A rider looking for one
|
|
// drop read forty lines to find it.
|
|
//
|
|
// What is left collapsed: who, when, how much, and a mark for anything that
|
|
// did not go to plan. The rest did not disappear — it is in the row's own
|
|
// detail panel, one tap away, which is where a rider wants it for ONE stop.
|
|
/// Opens a history row. The detail panel is built only while the row is open
|
|
/// — `AnimatedSize` around one child rather than a crossfade laying out both,
|
|
/// which is what removed the overflow stripe on collapse.
|
|
/// Opens a stop's record, and unfolds the groups the measurements live in.
|
|
///
|
|
/// The record page ranks its facts now: outcome, journey and proof lead, and
|
|
/// the ledger — distance, ETA, payment, reference — sits behind three folded
|
|
/// rows, tapped only when somebody in an office asks. The facts are
|
|
/// unchanged; reaching them costs a tap, so this takes it.
|
|
Future<void> open(WidgetTester tester, String name) async {
|
|
await tester.tap(find.text(name));
|
|
await tester.pumpAndSettle();
|
|
|
|
for (final group in const [
|
|
'Delivery details',
|
|
'Payment',
|
|
'Order details',
|
|
]) {
|
|
// Inside its own control: the app bar's title is also "Delivery
|
|
// details", and an ambiguous finder makes `tap` throw.
|
|
final row = find.descendant(
|
|
of: find.byType(InkWell),
|
|
matching: find.text(group),
|
|
);
|
|
if (row.evaluate().isEmpty) continue;
|
|
await tester.tap(row.first);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
}
|
|
|
|
testWidgets('the collapsed row is a name, a clock and nothing else', (
|
|
tester,
|
|
) async {
|
|
await pump(
|
|
tester,
|
|
done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)],
|
|
);
|
|
|
|
expect(find.text('Joe Mathew'), findsOneWidget);
|
|
|
|
// The address is detail for one stop, not a header for forty.
|
|
expect(find.text('12, SNS Colony, Coimbatore'), findsNothing);
|
|
// `titleLabel` led the verdict sentence and went with it.
|
|
expect(find.text('Delivery'), findsNothing);
|
|
// A norm stated on every row separates nothing.
|
|
expect(find.text('On time'), findsNothing);
|
|
|
|
// The filled state tag is still gone.
|
|
expect(find.byType(OrderStatusTag), findsNothing);
|
|
});
|
|
|
|
testWidgets('an exception is coloured; the norm is not', (tester) async {
|
|
await pump(
|
|
tester,
|
|
done: [
|
|
stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime),
|
|
stop(
|
|
'A2',
|
|
'Green Leaf',
|
|
status: 'picked',
|
|
minsAgo: 60,
|
|
compliance: late7,
|
|
),
|
|
],
|
|
);
|
|
|
|
// The principle is unchanged and is now absolute: the norm says NOTHING on
|
|
// the row, and the exception is the only thing that gets a word — in the
|
|
// warning colour, in a capsule, beside the stop it happened to.
|
|
expect(find.text('On time'), findsNothing);
|
|
expect(
|
|
tester.widget<Text>(find.text('Late 7m')).style!.color,
|
|
ColorConstants.warning,
|
|
);
|
|
|
|
// Being on route is the norm and is not said at all. Riding past it is —
|
|
// in the detail panel now, where the rest of the measurements live.
|
|
expect(find.text('On route'), findsNothing);
|
|
await open(tester, 'Green Leaf');
|
|
expect(find.textContaining('3.2 km'), findsWidgets);
|
|
});
|
|
|
|
testWidgets('a stop nothing was measured on says so', (tester) async {
|
|
await pump(tester, done: [stop('A1', 'Anitha Ramesh', status: 'picked')]);
|
|
|
|
// Said out loud, not implied by a missing row: `_detailRow` drops a null
|
|
// value, so silence here would be indistinguishable from a row that simply
|
|
// had not been rendered.
|
|
await open(tester, 'Anitha Ramesh');
|
|
expect(find.text('Not measured'), findsWidgets);
|
|
|
|
// A record that invents a plausible number is worse than one with a hole
|
|
// in it, because the hole can be investigated and the invention cannot be
|
|
// caught. None of these may appear in place of a missing measurement.
|
|
for (final invented in const ['0m', '0 min', '0 km', '0.0 km', '₹0']) {
|
|
expect(find.text(invented), findsNothing, reason: '"$invented" is a lie');
|
|
}
|
|
});
|
|
|
|
// ── The record is a page, not an accordion ──
|
|
//
|
|
// It used to open under the row. An open record is several screens tall, so
|
|
// the row below it left the viewport — and left what a lazy `ListView` had
|
|
// built — and the list got harder to scan in exact proportion to how much it
|
|
// was used. Its own assertions moved with it to
|
|
// `test/delivery_details_test.dart`; what stays here is that the row is the
|
|
// way in and nothing more.
|
|
testWidgets('tapping a row opens the record on its own page', (tester) async {
|
|
await pump(
|
|
tester,
|
|
done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)],
|
|
);
|
|
|
|
// Nothing of the record is on the list, open or closed.
|
|
expect(find.text('ETA kept'), findsNothing);
|
|
expect(find.byType(DeliveryDetailsPage), findsNothing);
|
|
|
|
await tester.tap(find.text('Joe Mathew'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(DeliveryDetailsPage), findsOneWidget);
|
|
// Twice: the app bar's title, and the first folded group under the
|
|
// journey. Both are legitimately called the same thing.
|
|
expect(find.text('Delivery details'), findsWidgets);
|
|
|
|
// And the bar's own back control leaves the rider where he was in the day.
|
|
// `pageBack()` hunts for a Material/Cupertino back button; `MilerAppBar`
|
|
// draws its own, so the test presses what the rider presses.
|
|
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(DeliveryDetailsPage), findsNothing);
|
|
expect(find.text('Joe Mathew'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a meal run has no money anywhere on the page', (tester) async {
|
|
await pump(
|
|
tester,
|
|
profile: ServiceProfile.milkMan,
|
|
done: [
|
|
// Cash on the payload, which a meal tenant must still never show:
|
|
// `collectsCash` is the one gate, and it is false here.
|
|
stop(
|
|
'M1',
|
|
'Priya Nair',
|
|
status: 'delivered',
|
|
cash: 220,
|
|
compliance: onTime,
|
|
),
|
|
],
|
|
);
|
|
|
|
await tester.tap(find.text('Priya Nair'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('COLLECTED'), findsNothing);
|
|
expect(find.text('PAYMENT'), findsNothing);
|
|
expect(find.textContaining('₹'), findsNothing);
|
|
|
|
// The shipment-desk fields and the record's own figures are the detail
|
|
// page's business — see `test/delivery_details_test.dart`.
|
|
expect(find.text('Weight'), findsNothing);
|
|
expect(find.text('Parcels handed over'), findsNothing);
|
|
});
|
|
|
|
testWidgets('an empty day is still something you can pull on', (
|
|
tester,
|
|
) async {
|
|
await pump(tester, settle: false);
|
|
|
|
// ── The empty state is a caption, not a screen ──
|
|
//
|
|
// It was `nothing_finished_yet.png` at 300pt wide on a 390pt phone — a
|
|
// picture larger than anything this page draws when it *does* have
|
|
// something to say, standing in for the absence of six short rows. One
|
|
// glyph, one headline, one line and the way out.
|
|
expect(
|
|
find.byWidgetPredicate(
|
|
(w) =>
|
|
w is Image &&
|
|
w.image is AssetImage &&
|
|
(w.image as AssetImage).assetName ==
|
|
'assets/images/nothing_finished_yet.png',
|
|
),
|
|
findsNothing,
|
|
reason: 'the oversized artwork must not come back',
|
|
);
|
|
// The empty state is the artwork plus the one control — see
|
|
// `activity_page_test`'s "artwork and one way out".
|
|
expect(find.textContaining('Go to'), findsOneWidget);
|
|
expect(find.textContaining('Go to'), findsOneWidget);
|
|
|
|
// Pull-to-refresh is the rider's only way out of an empty day, and it dies
|
|
// the moment the empty state stops being scrollable — which is what a
|
|
// `Center` in place of this `ListView` would do.
|
|
final list = tester.widget<ListView>(
|
|
find.byKey(const ValueKey('activity-empty')),
|
|
);
|
|
expect(list.physics, isA<AlwaysScrollableScrollPhysics>());
|
|
|
|
await tester.fling(
|
|
find.byKey(const ValueKey('activity-empty')),
|
|
const Offset(0, 320),
|
|
800,
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(find.byType(RefreshProgressIndicator), findsOneWidget);
|
|
for (var i = 0; i < 30; i++) {
|
|
await tester.pump(const Duration(milliseconds: 80));
|
|
}
|
|
});
|
|
|
|
testWidgets('a parked stop carries its reason and exactly one control', (
|
|
tester,
|
|
) async {
|
|
await pump(
|
|
tester,
|
|
done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)],
|
|
skipped: [
|
|
parked('A9', 'Sri Balaji Stores', 'Customer unreachable'),
|
|
parked('A8', 'Kumaran Silks', 'Shutter closed'),
|
|
],
|
|
);
|
|
|
|
// ── The lifted band is gone; the trip is the grouping now ──
|
|
//
|
|
// Parked work used to be pulled out of date order and led with, because in
|
|
// a single newest-first list a morning skip sank below everything finished
|
|
// since. The page is grouped by the rider's trips instead, so a skip sits
|
|
// in the run it happened on — which is where he looks for it — and the
|
|
// trip's own header counts it so a collapsed run still says one is owed.
|
|
//
|
|
// What must not change is the row itself: its reason, and one control.
|
|
expect(find.text('NEED A RETURN VISIT'), findsNothing);
|
|
|
|
// The reason is the one thing on the row he cannot reconstruct for
|
|
// himself, and it is read in amber at the size the address is — not a
|
|
// 10.5sp filled pill.
|
|
expect(find.textContaining('Skipped'), findsNWidgets(2));
|
|
|
|
final reason = tester.widget<Text>(find.text('Customer unreachable'));
|
|
expect(reason.style!.color, ColorConstants.warning);
|
|
expect(reason.style!.fontSize, greaterThanOrEqualTo(12.5));
|
|
|
|
// ── One control per parked row, and it is compact now ──
|
|
//
|
|
// Still exactly one per parked row and none on a finished one: a row
|
|
// offering a second action makes the rider choose before he can act, and
|
|
// there is nothing else to do with a stop he has put down.
|
|
//
|
|
// What changed is its weight. A full-width filled button on every parked
|
|
// row made the exception look like the rule on a page that is otherwise
|
|
// pure history — so it is a compact inline control, at a real tap target,
|
|
// with the same in-flight guard the button had.
|
|
expect(find.text('Resume this stop'), findsNothing);
|
|
expect(find.text('Resume stop'), findsNWidgets(2));
|
|
expect(
|
|
find.byType(MilerButton),
|
|
findsNothing,
|
|
reason: 'a history list is not a page of actions',
|
|
);
|
|
});
|
|
}
|