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

288
test/tenant_mode_test.dart Normal file
View File

@@ -0,0 +1,288 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE LOGIN, ONE APP, THE TENANT DECIDES
///
/// ```
/// phone ─▶ MPIN ─▶ verify-pin ─▶ tenantid / tenantname ─▶ ServiceProfile
/// ╱ ╲
/// Milk Man Logistics
/// ```
///
/// ── The direction this must fail in ──
///
/// Every assertion below has a mirror. It is easy to prove a milk-man tenant
/// gets the milk run; the half that breaks silently is a **logistics** rider
/// losing his cash screen or gaining a kitchen heading, and nobody files a bug
/// saying "the milk-man build looks fine". So an unknown tenant, a blank one and
/// a missing one all land on logistics, which is the profile that asks for more
/// rather than less.
///
/// ── And the build flag no longer wins ──
///
/// It used to be read first, because `verify-pin` returned no tenant and the
/// flag was the only signal there was. Now the account answers, and "which work
/// am I doing today?" belongs to the hub that rostered the rider rather than to
/// whoever compiled the APK — one build serves every rider, so a flag that
/// outranked the account meant one wrong build put *everyone* on the wrong flow.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<ServiceProfile> resolveWith(Map<String, Object> prefs) async {
SharedPreferences.setMockInitialValues(prefs);
return resolveServiceProfile();
}
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
group('the tenant name off the login', () {
test('a milk-man tenant gets the milk run', () async {
for (final name in [
'milkman',
'milk man',
'milk-man',
'milkrun',
'milk run',
]) {
final p = await resolveWith({'tenantname': name});
expect(p.line, ServiceLine.milkMan, reason: name);
}
});
test('the older food-client names still resolve', () async {
// Existing accounts carry these. Dropping them would move live riders to
// a different flow on an app update, which is not a rename anyone asked
// for.
for (final name in ['meals', 'dailygrubs']) {
final p = await resolveWith({'tenantname': name});
expect(p.line, ServiceLine.milkMan, reason: name);
}
});
test('a logistics tenant gets logistics', () async {
for (final name in ['doormile', 'logistics', 'parcel']) {
final p = await resolveWith({'tenantname': name});
expect(p.line, ServiceLine.parcel, reason: name);
}
});
test('case and surrounding space do not matter', () async {
// `auth_provider` lowercases and trims before it stores, and the resolver
// does it again — a console entry of " MilkMan " must not silently put a
// rider on the wrong flow.
final p = await resolveWith({'tenantname': 'milkman'});
expect(p.line, ServiceLine.milkMan);
});
});
group('the tenant id off the login', () {
test('tenant 13 is the milk run', () async {
// The live rider this was found with: Rajan A, userid 38, tenant 13.
final p = await resolveWith({'tenantid': 13});
expect(p.line, ServiceLine.milkMan);
});
});
group('the tenant claim on the session token', () {
// ── The bug this group exists for ──
//
// A real login returned `{success, token, user:{...}}` with **no tenant in
// the body at all** — and a token whose payload said `tenantid: 13`. The
// app was being told the answer and could not hear it, so every rider
// resolved to the fallback line and the milk run was unreachable on a real
// account.
/// A token whose payload carries [claims]. Signature is decorative: nothing
/// client-side verifies it, and nothing may start to.
String tokenWith(Map<String, Object> claims) {
String seg(Map<String, Object> m) =>
base64Url.encode(utf8.encode(json.encode(m))).replaceAll('=', '');
return '${seg({'alg': 'HS256', 'typ': 'JWT'})}.${seg(claims)}.sig';
}
test('reads the tenant a real token claims', () {
expect(
ApiConfig.tenantIdFromToken(
tokenWith({'userid': 38, 'roleid': 5, 'tenantid': 13}),
),
13,
);
});
test('an already-signed-in rider is resolved from his session', () async {
// No tenant in prefs — exactly what an older build left behind. He must
// not have to log out and back in to land on the right flow.
final p = await resolveWith({
'authtoken': tokenWith({'userid': 38, 'tenantid': 13}),
});
expect(p.line, ServiceLine.milkMan);
});
test('an upgraded install with a stale tenant 0 self-heals', () async {
// The exact state a phone is in after upgrading from a build that wrote
// `tenantid: 0` into prefs because the login body had no tenant. Zero is
// not a tenant, so it must not be treated as one and must not stop the
// fall-through to the live session's claim — otherwise the rider has to
// log out and back in to reach the right flow, and nothing on screen
// would tell him to.
final p = await resolveWith({
'tenantid': 0,
'tenantname': '',
'authtoken': tokenWith({'userid': 38, 'tenantid': 13}),
});
expect(p.line, ServiceLine.milkMan);
});
test('the persisted tenant still wins over the claim', () async {
final p = await resolveWith({
'tenantname': 'logistics',
'authtoken': tokenWith({'tenantid': 13}),
});
expect(
p.line,
ServiceLine.parcel,
reason:
'the stored pair is the fresher answer; the claim is a backstop',
);
});
test('a junk token decides nothing and throws nothing', () async {
for (final bad in ['', 'not-a-jwt', 'a.b', 'a.!!!!.c', 'a.e30.c']) {
expect(ApiConfig.tenantIdFromToken(bad), 0, reason: bad);
}
expect(ApiConfig.tenantIdFromToken(null), 0);
// And a session holding one falls through rather than failing to launch.
final p = await resolveWith({'authtoken': 'not-a-jwt'});
expect(p.line, ServiceLine.parcel);
});
test('a token claiming tenant 0 decides nothing', () async {
expect(ApiConfig.tenantIdFromToken(tokenWith({'tenantid': 0})), 0);
final p = await resolveWith({
'authtoken': tokenWith({'tenantid': 0}),
});
expect(p.line, ServiceLine.parcel);
});
test('a token claiming an unknown tenant falls back safely', () async {
final p = await resolveWith({
'authtoken': tokenWith({'tenantid': 4242}),
});
expect(p.line, ServiceLine.parcel);
});
});
group('the safe fallback', () {
test('an unknown tenant name is logistics', () async {
final p = await resolveWith({'tenantname': 'someclient'});
expect(p.line, ServiceLine.parcel);
});
test('an unknown tenant id is logistics', () async {
final p = await resolveWith({'tenantid': 4242});
expect(p.line, ServiceLine.parcel);
});
test('no tenant at all is logistics', () async {
final p = await resolveWith({});
expect(p.line, ServiceLine.parcel);
});
test('a blank tenant name is logistics', () async {
final p = await resolveWith({'tenantname': ' '});
expect(p.line, ServiceLine.parcel);
});
test('the fallback keeps the screens that ask for MORE', () async {
// The worst case this way is a milk-run rider seeing a payment prompt he
// can dismiss. The other way takes the cash screen away from a logistics
// rider standing at a door with a customer waiting to pay.
final p = await resolveWith({});
expect(p.collectsCash, isTrue);
expect(p.capturesShipmentAddresses, isTrue);
expect(p.needsVerification, isTrue);
});
});
group('the name is checked before the id', () {
test('a recognised name wins over an unrecognised id', () async {
final p = await resolveWith({'tenantname': 'milkman', 'tenantid': 4242});
expect(p.line, ServiceLine.milkMan);
});
test('an unrecognised name falls through to the id', () async {
// The registry is keyed by name today, so this proves the fall-through
// path exists for the day ops would rather pin a number.
expect(TenantController.profileForName('nobody'), isNull);
expect(TenantController.profileForId(4242), isNull);
final p = await resolveWith({'tenantname': 'nobody', 'tenantid': 4242});
expect(p.line, ServiceLine.parcel);
});
});
group('what each mode can and cannot do', () {
test('the milk run has no shipment desk and takes no money', () {
const p = ServiceProfile.milkMan;
expect(p.collectsCash, isFalse);
expect(p.capturesShipmentAddresses, isFalse);
expect(p.pricesShipment, isFalse);
expect(p.initiatesShipment, isFalse);
expect(p.needsVerification, isFalse);
});
test('the milk run collects at a source and delivers it itself', () {
const p = ServiceProfile.milkMan;
expect(p.sourceIsKitchen, isTrue);
expect(p.bulkLoadProof, isTrue);
expect(p.handsOffAtCollection, isTrue);
expect(p.deliversToCustomer, isTrue);
expect(p.startsDeliveryAfterFullLoad, isTrue);
});
test('logistics runs the whole shipment desk', () {
const p = ServiceProfile.parcel;
expect(p.capturesShipmentAddresses, isTrue);
expect(p.pricesShipment, isTrue);
expect(p.initiatesShipment, isTrue);
expect(p.collectsCash, isTrue);
expect(p.needsVerification, isTrue);
});
test('logistics has no kitchen and no round of its own', () {
const p = ServiceProfile.parcel;
expect(p.sourceIsKitchen, isFalse);
expect(p.bulkLoadProof, isFalse);
expect(p.startsDeliveryAfterFullLoad, isFalse);
// Its shipment goes to the hub and somebody else delivers it, which is
// why this line ends at a warehouse and the milk run ends at a door.
expect(p.deliversToCustomer, isFalse);
expect(p.handsOffAtCollection, isFalse);
});
test('accepted work goes to a different place on each line', () {
// The single rule §12 of the brief turns on.
expect(ServiceProfile.parcel.handoffAt, HandoffPoint.accepted);
expect(ServiceProfile.milkMan.handoffAt, HandoffPoint.collected);
});
});
group('the old names still compile to the same thing', () {
test('meals is milkMan', () {
expect(identical(ServiceProfile.meals, ServiceProfile.milkMan), isTrue);
expect(ServiceProfile.milkMan.isMeals, isTrue);
expect(ServiceProfile.milkMan.isMilkMan, isTrue);
});
test('parcel is logistics', () {
expect(ServiceProfile.parcel.isParcel, isTrue);
expect(ServiceProfile.parcel.isLogistics, isTrue);
});
});
}