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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -0,0 +1,217 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/service_day.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ACTIVITY IS TODAY, IN THREE TRIPS
///
/// The page answered "everything you have ever finished, newest first", cut
/// into date bands. The rider's question is narrower and shaped differently:
/// **how did Trip 2 go.** He was given three runs this morning; he wants the
/// record back in the same three.
///
/// These pin the four properties that makes it that record rather than a feed:
/// the grouping is the hub's, the order inside a trip is the hub's, the day is
/// today's, and nothing internal leaks onto the screen.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({'userid': 38});
ServiceProfile.setActive(ServiceProfile.milkMan);
});
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> done(
String id, {
int step = 0,
DateTime? at,
String status = 'delivered',
String? tripId,
String? day,
}) => {
'orderid': id,
'pickupid': int.tryParse(id) ?? id.hashCode.abs(),
'orderstatus': status,
'step': step,
'pickupcustomer': 'Customer $id',
if (tripId != null) 'tripid': tripId,
if (at != null) 'expected_pickup_time': at.toIso8601String(),
if (at != null) 'completedat': at.toIso8601String(),
'completedday': day ?? ServiceDay.today,
};
List<String> ids(List<Map<String, dynamic>> s) => [
for (final x in s) x['orderid'] as String,
];
group('grouped into the rider’s three trips', () {
test('a three-run day produces Trip 1, Trip 2 and Trip 3', () {
final trips = Trip.groupIntoTrips([
done('a', at: DateTime(2026, 8, 21, 9)),
done('b', at: DateTime(2026, 8, 21, 14)),
done('c', at: DateTime(2026, 8, 21, 19)),
]);
expect(trips.map((t) => t.tripNumber), [1, 2, 3]);
});
test('a completed stop stays under the trip it belonged to', () {
final trips = Trip.groupIntoTrips([
done('morning-1', at: DateTime(2026, 8, 21, 8)),
done('morning-2', at: DateTime(2026, 8, 21, 11)),
done('evening-1', at: DateTime(2026, 8, 21, 18)),
]);
final slots = trips.slots;
expect(ids(slots[0]!.stops), ['morning-1', 'morning-2']);
expect(slots[1], isNull, reason: 'nothing was done in the afternoon');
expect(ids(slots[2]!.stops), ['evening-1']);
});
test('the hub’s own trip id beats the clock', () {
// Two stops eleven hours apart that the hub calls one run. The day-part
// rule is a fallback for when it says nothing — never an override.
final trips = Trip.groupIntoTrips([
done('a', at: DateTime(2026, 8, 21, 8), tripId: 'T7'),
done('b', at: DateTime(2026, 8, 21, 19), tripId: 'T7'),
]);
expect(trips.length, 1);
expect(ids(trips.single.stops), ['a', 'b']);
});
test('a trip with no dates at all still lands in a slot', () {
// No timestamps anywhere. It must not vanish, and it must not be
// labelled with an internal data-quality word.
final trips = Trip.groupIntoTrips([done('a'), done('b')]);
expect(trips.single.stops, hasLength(2));
expect(trips.slots[0], isNotNull);
});
});
group('the admin sequence survives into the record', () {
test('a trip shows its stops in the hub’s order, not by finish time', () {
// Finished out of order — c first, a last — but the run was planned
// a → b → c, and the record of a planned run is the plan.
final trips = Trip.groupIntoTrips([
done('c', step: 3, at: DateTime(2026, 8, 21, 9, 5)),
done('a', step: 1, at: DateTime(2026, 8, 21, 9, 40)),
done('b', step: 2, at: DateTime(2026, 8, 21, 9, 20)),
]);
expect(ids(trips.single.stops), ['a', 'b', 'c']);
expect(RouteOrder.sequenceOf(trips.single.stops.first), 1);
});
test('grouping never re-bases the sequence per trip', () {
final trips = Trip.groupIntoTrips([
done('a5', step: 5, at: DateTime(2026, 8, 21, 15)),
done('a4', step: 4, at: DateTime(2026, 8, 21, 14)),
done('m1', step: 1, at: DateTime(2026, 8, 21, 9)),
]);
expect(ids(trips.slots[1]!.stops), ['a4', 'a5']);
expect(RouteOrder.sequenceOf(trips.slots[1]!.stops.first), 4);
});
});
group('today only, without a timer', () {
test('yesterday is not today’s activity', () {
final yesterday = ServiceDay.stamp(
DateTime.now().subtract(const Duration(days: 1)),
);
expect(ServiceDay.belongsToToday(done('a', day: yesterday)), isFalse);
expect(ServiceDay.belongsToToday(done('b')), isTrue);
});
test('the rollover needs nothing to run at midnight', () {
// The same record, asked about on two different days. Nothing was
// deleted and no timer fired — the answer is computed at read time,
// which is the only thing that survives the app being closed at 23:59.
final row = done('a', day: '2026-08-21');
expect(ServiceDay.belongsToToday(row, now: '2026-08-21'), isTrue);
expect(ServiceDay.belongsToToday(row, now: '2026-08-22'), isFalse);
});
test('a record with no date is kept, quietly', () {
// A payload defect is not evidence the work happened yesterday, and it
// is not something to print on the rider's screen either.
final row = {'orderid': 'x', 'orderstatus': 'delivered'};
expect(ServiceDay.belongsToToday(row), isTrue);
expect(ServiceDay.of(row), '');
});
test('a timestamp is used when no day stamp was written', () {
final row = {
'orderid': 'x',
'completedat': DateTime.now().toIso8601String(),
};
expect(ServiceDay.belongsToToday(row), isTrue);
});
test('the store and the reader agree on what a day is', () async {
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
final filed = (await getCompletedBookings()).single;
expect(
ServiceDay.of(filed),
ServiceDay.today,
reason:
'two implementations of a date format is how a shift ends up '
'half in one day and half in the next',
);
});
});
group('nothing internal reaches the rider', () {
test('the work-type mark never says MILK', () {
// The service profile stays — it is what separates one rider's records
// from another's. What goes is printing its name on his screen.
expect(ServiceProfile.milkMan.workTypeLabel, isNot('MILK'));
expect(ServiceProfile.milkMan.workTypeLabel, 'DELIVERY');
// Logistics never had a single-type mark and still does not — its stops
// genuinely differ, so each one names itself.
expect(ServiceProfile.parcel.workTypeLabel, isNull);
expect(ServiceProfile.parcel.usesSingleWorkType, isFalse);
});
test('records are still isolated by owner, in the data', () async {
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
expect(await getCompletedOrderIds(), contains('a'));
SharedPreferences.setMockInitialValues({'userid': 99});
expect(
await getCompletedOrderIds(),
isEmpty,
reason: 'separation is an ownership rule, not a label on a card',
);
});
test('the other line’s records are not this one’s', () async {
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
ServiceProfile.setActive(ServiceProfile.parcel);
expect(await getCompletedOrderIds(), isEmpty);
});
});
group('delivered and skipped stay different things', () {
test('a skip is filed as a skip, never as a cancellation', () async {
await addCompletedBookings(
[done('a', status: 'outfordelivery')],
cancelled: true,
terminalStatus: 'skipped',
);
final filed = (await getCompletedBookings()).single;
expect(filed['orderstatus'], 'skipped');
expect(filed['orderstatus'], isNot('cancelled'));
});
test('a skip keeps the reason the rider gave', () async {
await addSkippedBooking(done('a'), reason: 'Shutter down');
final parked = (await getSkippedBookings()).single;
expect(parked['skipreason'], 'Shutter down');
expect(parked['orderstatus'], 'skipped');
});
});
}