Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
@@ -336,11 +336,14 @@ class MilerApi {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// AUTH (3 routes — reset-pin is intentionally not one of them)
|
||||
// AUTH (4 routes — reset-pin is intentionally not one of them)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Step 1. 404 when there is no account, 403 when the row is not role 5 or
|
||||
/// not Active — both surface as `ok: false` with the server's message.
|
||||
///
|
||||
/// Answers `{success, message, phone, pin_set}` — **top level, not under
|
||||
/// `data`**. Read the branch with [pinSetOf]; never off the message string.
|
||||
static Future<ApiResult> login(String phone) => _send(
|
||||
'POST',
|
||||
'/miler/login',
|
||||
@@ -352,6 +355,79 @@ class MilerApi {
|
||||
},
|
||||
);
|
||||
|
||||
/// Does this account already have a PIN on file?
|
||||
///
|
||||
/// ── The one thing the login response is asked for ──
|
||||
///
|
||||
/// Riders set their own PIN on first sign-in now; the console no longer
|
||||
/// issues one. `pin_set` is how the server says which of the two screens the
|
||||
/// rider is owed, and it is a **boolean** — the message beside it is prose
|
||||
/// and prose gets reworded. Branching on "PIN verification required" would
|
||||
/// break the day somebody improves that sentence.
|
||||
///
|
||||
/// Null when the response did not carry the field at all: an older server, or
|
||||
/// a failure. A caller that cannot tell should send the rider to Enter-PIN,
|
||||
/// which is the safe direction — a rider who does have a PIN can use it, and
|
||||
/// one who does not gets a refusal he can report, rather than being walked
|
||||
/// into a Set-PIN screen that will 409.
|
||||
static bool? pinSetOf(ApiResult res) {
|
||||
final raw = res.raw;
|
||||
final v = raw is Map ? (raw['pin_set'] ?? raw['pinSet']) : null;
|
||||
if (v is bool) return v;
|
||||
if (v is String) {
|
||||
final t = v.toLowerCase();
|
||||
if (t == 'true') return true;
|
||||
if (t == 'false') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// First-time PIN creation, self-service. `POST /miler/set-pin`.
|
||||
///
|
||||
/// ── This route did not exist, and its absence shaped the whole screen ──
|
||||
///
|
||||
/// The only PIN-write the backend had was `POST /miler/reset-pin`, which
|
||||
/// needs an ADMIN token — it was once open, and reset-pin followed by
|
||||
/// verify-pin took over any rider account given nothing but a phone number.
|
||||
/// So the app could not let a rider set a PIN at all, and `AuthProvider`
|
||||
/// answered its own Create-MPIN screen with a manufactured 403 telling him
|
||||
/// his office issues it.
|
||||
///
|
||||
/// That is no longer true. This route is rider-authenticated by phone,
|
||||
/// **cannot overwrite an existing PIN** (409 if one is set), and returns a
|
||||
/// full session — the same `{token, user}` shape as [verifyPin] — so the
|
||||
/// rider lands signed in without a second round trip.
|
||||
///
|
||||
/// Refusals: `404` no such phone, `403` inactive or not a miler, `409` a PIN
|
||||
/// already exists — send that rider to Enter-PIN instead.
|
||||
static Future<ApiResult> setPin({
|
||||
required String phone,
|
||||
required String pin,
|
||||
String? deviceToken,
|
||||
}) async {
|
||||
final res = await _send(
|
||||
'POST',
|
||||
'/miler/set-pin',
|
||||
auth: false,
|
||||
body: {
|
||||
'phone': phone,
|
||||
'new_pin': pin,
|
||||
'configid': configId,
|
||||
if (hasTenantId) 'tenantid': tenantId,
|
||||
if (deviceToken != null && deviceToken.isNotEmpty)
|
||||
'device_token': deviceToken,
|
||||
},
|
||||
);
|
||||
// Same token handling as verify-pin, deliberately: this IS a sign-in, and
|
||||
// a second implementation of "where does the session come from" is how the
|
||||
// two paths drift.
|
||||
if (res.ok && res.raw is Map) {
|
||||
final token = _str((res.raw as Map)['token']);
|
||||
if (token.isNotEmpty) await ApiConfig.setToken(token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Step 2. On success the token is stored and the caller gets `user`.
|
||||
///
|
||||
/// The login is under `user` / `user.profile` — there is no `data` key on
|
||||
@@ -1001,10 +1077,24 @@ class MilerApi {
|
||||
///
|
||||
/// The signature expires in ten minutes, so a failed upload is re-*signed*
|
||||
/// rather than retried against the old URL.
|
||||
/// ── A pickup proof has no consignment, and must not pretend to ──
|
||||
///
|
||||
/// The server builds the storage key as `{folder}/{tag}-{id}-…`, choosing the
|
||||
/// id from `consignmentid`, then `bookingid`, then the rider. A pickup photo
|
||||
/// is taken *before* `pickup-complete` mints anything, so there is no
|
||||
/// consignment to key it on — and this method only offered `consignmentid`.
|
||||
/// The one caller that needed it passed a **booking** id in that slot, so
|
||||
/// every pickup proof this app has ever uploaded was filed under a
|
||||
/// consignment number that does not exist, colliding with whatever real
|
||||
/// consignment later took it.
|
||||
///
|
||||
/// [bookingId] is the field the server already has for this case. Send
|
||||
/// whichever one the stop actually has.
|
||||
static Future<ApiResult> signUpload({
|
||||
required String purpose,
|
||||
String contentType = 'image/jpeg',
|
||||
Object? consignmentId,
|
||||
Object? bookingId,
|
||||
}) => _send(
|
||||
'POST',
|
||||
'/miler/uploads/sign',
|
||||
@@ -1012,6 +1102,7 @@ class MilerApi {
|
||||
'purpose': purpose,
|
||||
'contentType': contentType,
|
||||
if (consignmentId != null) 'consignmentid': consignmentId,
|
||||
if (bookingId != null) 'bookingid': bookingId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1034,6 +1125,35 @@ class MilerApi {
|
||||
File file, {
|
||||
required String purpose,
|
||||
Object? consignmentId,
|
||||
Object? bookingId,
|
||||
}) async =>
|
||||
(await uploadProofRef(
|
||||
file,
|
||||
purpose: purpose,
|
||||
consignmentId: consignmentId,
|
||||
bookingId: bookingId,
|
||||
))?.url;
|
||||
|
||||
/// The same upload, returning **both** references the platform uses.
|
||||
///
|
||||
/// ── Why the key matters, and why it was being thrown away ──
|
||||
///
|
||||
/// `POST /miler/uploads/sign` answers with `uploadurl`, `url` **and `key`**.
|
||||
/// This method used to keep only `url` and discard the key, which was fine
|
||||
/// while the only consumer was `deliver` — that route takes a `photourl`.
|
||||
///
|
||||
/// It is not fine for `POST /miler/bookings/:id/parcel`, whose `photos` field
|
||||
/// wants the **storage key**, not a URL. The server's own note says why: the
|
||||
/// customer is served a short-lived signed link *derived from* the key, never
|
||||
/// a permanent one. Sending a URL there would store a link that either
|
||||
/// expires or, worse, never does.
|
||||
///
|
||||
/// So both come back and each caller takes the one its route is specified in.
|
||||
static Future<UploadRef?> uploadProofRef(
|
||||
File file, {
|
||||
required String purpose,
|
||||
Object? consignmentId,
|
||||
Object? bookingId,
|
||||
}) async {
|
||||
if (!file.existsSync()) return null;
|
||||
|
||||
@@ -1045,6 +1165,7 @@ class MilerApi {
|
||||
purpose: purpose,
|
||||
contentType: contentType,
|
||||
consignmentId: consignmentId,
|
||||
bookingId: bookingId,
|
||||
);
|
||||
if (!signed.ok) {
|
||||
debugPrint('[UPLOAD] sign failed: ${signed.status} ${signed.message}');
|
||||
@@ -1057,6 +1178,7 @@ class MilerApi {
|
||||
.toString()
|
||||
.trim();
|
||||
final publicUrl = (map?['url'] ?? '').toString().trim();
|
||||
final objectKey = (map?['key'] ?? '').toString().trim();
|
||||
if (uploadUrl.isEmpty || publicUrl.isEmpty) {
|
||||
debugPrint('[UPLOAD] sign returned no url pair');
|
||||
return null;
|
||||
@@ -1078,7 +1200,9 @@ class MilerApi {
|
||||
body: await file.readAsBytes(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) return publicUrl;
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return UploadRef(url: publicUrl, key: objectKey);
|
||||
}
|
||||
debugPrint('[UPLOAD] PUT ${res.statusCode} — ${res.body}');
|
||||
} catch (e) {
|
||||
debugPrint('[UPLOAD] PUT failed: $e');
|
||||
@@ -1169,7 +1293,10 @@ class MilerApi {
|
||||
if (heading != null) 'heading': _str(heading),
|
||||
if (accuracy != null) 'accuracy': _str(accuracy),
|
||||
if (status != null && status.isNotEmpty) 'status': status,
|
||||
if (orderId != null) 'orderid': _str(orderId),
|
||||
// The device's own stop key can carry a `#seq` destination suffix (see
|
||||
// ApiConfig's adapter); the server has never seen one and this is a
|
||||
// breadcrumb tag, not a join key. Send the booking's half.
|
||||
if (orderId != null) 'orderid': _str(orderId).split('#').first,
|
||||
if (battery != null) 'battery': _str(battery),
|
||||
'is_charging': isCharging,
|
||||
if (connection != null) 'connection': connection,
|
||||
@@ -1298,6 +1425,26 @@ class MilerApi {
|
||||
/// Dimensions are centimetres and weight is kilograms. They are what
|
||||
/// `pickup-complete` recomputes chargeable weight from, so a parcel submitted
|
||||
/// without them bills on the booked figure rather than the real one.
|
||||
/// Where an uploaded image lives, in the two forms the platform uses.
|
||||
///
|
||||
/// `deliver` takes [url] as `photourl`; `parcel` takes [key] as one entry of
|
||||
/// `photos`. Neither is a substitute for the other — see [MilerApi.uploadProofRef].
|
||||
@immutable
|
||||
class UploadRef {
|
||||
const UploadRef({required this.url, required this.key});
|
||||
|
||||
/// The object's public URL.
|
||||
final String url;
|
||||
|
||||
/// The object's storage key — `{folder}/{tag}-{id}-{date}-{time}-{rand}.{ext}`.
|
||||
///
|
||||
/// May be empty if an older server build does not return one; a caller that
|
||||
/// needs it must treat empty as "no photo" rather than sending a blank entry.
|
||||
final String key;
|
||||
|
||||
bool get hasKey => key.isNotEmpty;
|
||||
}
|
||||
|
||||
class ParcelEntry {
|
||||
final Object? parcelId;
|
||||
final double weight;
|
||||
@@ -1305,12 +1452,33 @@ class ParcelEntry {
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
/// Storage keys of the photographs taken of this parcel at the door.
|
||||
///
|
||||
/// ── The field the app never sent ──
|
||||
///
|
||||
/// `POST /miler/bookings/:id/parcel` has accepted `photos` for as long as the
|
||||
/// route has existed, and the server's own note says why it matters: *"Weight
|
||||
/// without a photograph is a number the customer has no way to check, and
|
||||
/// this is the only point in the flow where anyone is standing next to the
|
||||
/// parcel."*
|
||||
///
|
||||
/// Meanwhile the rider app **compels** the photograph — `StopVerificationPage`
|
||||
/// will not let him confirm without one — uploaded it to object storage, and
|
||||
/// then dropped the reference on the floor. The evidence existed in a bucket
|
||||
/// nobody could search, for every CX pickup this app has ever done.
|
||||
///
|
||||
/// **Keys, not URLs.** The customer is served a short-lived signed link
|
||||
/// derived from the key; a URL stored here either expires or never does.
|
||||
/// See [MilerApi.uploadProofRef].
|
||||
final List<String> photos;
|
||||
|
||||
const ParcelEntry({
|
||||
this.parcelId,
|
||||
required this.weight,
|
||||
this.length = 0,
|
||||
this.width = 0,
|
||||
this.height = 0,
|
||||
this.photos = const <String>[],
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -1319,6 +1487,10 @@ class ParcelEntry {
|
||||
'length': length,
|
||||
'width': width,
|
||||
'height': height,
|
||||
// Omitted entirely when there is none, rather than sent as `[]`: an empty
|
||||
// array is a claim that no photograph was taken, and a failed upload is
|
||||
// not that claim.
|
||||
if (photos.isNotEmpty) 'photos': photos,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user