production
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -363,11 +364,22 @@ class MilerApi {
|
||||
|
||||
static Future<ApiResult> getProfile() => _send('GET', '/miler/profile');
|
||||
|
||||
/// ── Email and address are saveable now ──
|
||||
///
|
||||
/// The edit screen has always collected both and only `displayname` could be
|
||||
/// sent, so the other two lived on the device and were lost on a reinstall.
|
||||
/// The handler takes them as of this contract.
|
||||
///
|
||||
/// **Email is unique across users.** A collision comes back `409` with code
|
||||
/// `EMAIL_IN_USE`; sending the address the rider already has is a no-op
|
||||
/// rather than a conflict. Branch on [ApiResult.code], never the message.
|
||||
static Future<ApiResult> updateProfile({
|
||||
String? displayName,
|
||||
String? profilePhotoUrl,
|
||||
String? defaultVehicleType,
|
||||
String? phone,
|
||||
String? email,
|
||||
String? address,
|
||||
}) => _send(
|
||||
'PUT',
|
||||
'/miler/profile',
|
||||
@@ -376,6 +388,8 @@ class MilerApi {
|
||||
if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl,
|
||||
if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (email != null) 'email': email,
|
||||
if (address != null) 'address': address,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -827,6 +841,157 @@ class MilerApi {
|
||||
),
|
||||
);
|
||||
|
||||
/// ── Skipping a stop the rider has **not** collected yet ──
|
||||
///
|
||||
/// `POST /miler/consignments/:id/skip` keys on a consignment, and a booking
|
||||
/// he has not picked up does not have one — so the pre-pickup case had no
|
||||
/// route at all and the app recorded it locally, where the hub could not see
|
||||
/// it. This is that route.
|
||||
///
|
||||
/// It keeps the booking **assigned and resumable** (`resumable: true` comes
|
||||
/// back on success), which is the whole difference from
|
||||
/// [cancelBooking]: cancel gives the booking up and releases it for
|
||||
/// reassignment; this says *not now*.
|
||||
///
|
||||
/// Which of the three to call, in one line each:
|
||||
///
|
||||
/// ```
|
||||
/// not collected yet → skipBooking (this) resumable
|
||||
/// already collected → skipConsignment attemptcount++
|
||||
/// giving it up → cancelBooking released
|
||||
/// ```
|
||||
static Future<ApiResult> skipBooking(
|
||||
Object bookingId, {
|
||||
required String reason,
|
||||
double? lat,
|
||||
double? lon,
|
||||
}) => _guarded(
|
||||
'skip-booking:$bookingId',
|
||||
() => _send(
|
||||
'POST',
|
||||
'/miler/bookings/$bookingId/skip',
|
||||
idempotencyKey: _idempotencyKey('skip-booking', bookingId),
|
||||
body: {
|
||||
'reason': reason,
|
||||
if (lat != null) 'lat': lat,
|
||||
if (lon != null) 'lon': lon,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PROOF UPLOAD — a signed URL, then a direct PUT
|
||||
//
|
||||
// The photograph a rider takes at a door had nowhere to go: `deliver` takes
|
||||
// `photourl`, which wants a URL, and nothing on the contract accepted an
|
||||
// upload — so proof lived on the phone and died with the next reinstall.
|
||||
//
|
||||
// Two steps, and the second does not touch this API at all:
|
||||
//
|
||||
// 1. POST /miler/uploads/sign → { uploadurl, url, method, headers }
|
||||
// 2. PUT the bytes to `uploadurl` with **exactly** those headers
|
||||
// 3. send `url` as `photourl` on deliver
|
||||
//
|
||||
// The object-store credentials stay server-side, which is the point of the
|
||||
// signed-URL shape: the app never holds a key.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// What a proof upload is for. The server picks the bucket path from this,
|
||||
/// so it is not free text.
|
||||
static const String proofDelivery = 'delivery_proof';
|
||||
static const String proofPickup = 'pickup_proof';
|
||||
static const String proofSignature = 'receiver_signature';
|
||||
static const String proofSupport = 'support';
|
||||
|
||||
/// Step 1 — asks for somewhere to put an image.
|
||||
///
|
||||
/// The signature expires in ten minutes, so a failed upload is re-*signed*
|
||||
/// rather than retried against the old URL.
|
||||
static Future<ApiResult> signUpload({
|
||||
required String purpose,
|
||||
String contentType = 'image/jpeg',
|
||||
Object? consignmentId,
|
||||
}) => _send(
|
||||
'POST',
|
||||
'/miler/uploads/sign',
|
||||
body: {
|
||||
'purpose': purpose,
|
||||
'contentType': contentType,
|
||||
if (consignmentId != null) 'consignmentid': consignmentId,
|
||||
},
|
||||
);
|
||||
|
||||
/// Steps 1–3 in one call: sign, PUT the bytes, hand back the public URL.
|
||||
///
|
||||
/// Returns `null` when the photograph could not be uploaded, which is a
|
||||
/// **survivable** answer and never a reason to block a hand-over — the
|
||||
/// delivery is the thing that matters and `deliver` accepts an empty
|
||||
/// `photourl`. The caller records the delivery either way and keeps the
|
||||
/// local copy, so nothing is lost that was not already only local.
|
||||
///
|
||||
/// ── Why the headers are sent back verbatim ──
|
||||
///
|
||||
/// `x-amz-acl` is part of what was signed. Dropping it, or adding a header
|
||||
/// of our own, invalidates the signature and the store answers 403 — so the
|
||||
/// map returned by the sign call is used as-is rather than merged with
|
||||
/// anything this app thinks a request should carry. In particular the
|
||||
/// bearer token must **not** go to the object store.
|
||||
static Future<String?> uploadProof(
|
||||
File file, {
|
||||
required String purpose,
|
||||
Object? consignmentId,
|
||||
}) async {
|
||||
if (!file.existsSync()) return null;
|
||||
|
||||
final contentType = file.path.toLowerCase().endsWith('.png')
|
||||
? 'image/png'
|
||||
: 'image/jpeg';
|
||||
|
||||
final signed = await signUpload(
|
||||
purpose: purpose,
|
||||
contentType: contentType,
|
||||
consignmentId: consignmentId,
|
||||
);
|
||||
if (!signed.ok) {
|
||||
debugPrint('[UPLOAD] sign failed: ${signed.status} ${signed.message}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = signed.data;
|
||||
final map = data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
final uploadUrl = (map?['uploadurl'] ?? map?['uploadUrl'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final publicUrl = (map?['url'] ?? '').toString().trim();
|
||||
if (uploadUrl.isEmpty || publicUrl.isEmpty) {
|
||||
debugPrint('[UPLOAD] sign returned no url pair');
|
||||
return null;
|
||||
}
|
||||
|
||||
final headers = <String, String>{};
|
||||
final raw = map?['headers'];
|
||||
if (raw is Map) {
|
||||
raw.forEach((k, v) => headers['$k'] = '$v');
|
||||
}
|
||||
// The store needs a content type even if the signer did not name one.
|
||||
headers.putIfAbsent('Content-Type', () => contentType);
|
||||
|
||||
try {
|
||||
final res = await http
|
||||
.put(
|
||||
Uri.parse(uploadUrl),
|
||||
headers: headers,
|
||||
body: await file.readAsBytes(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) return publicUrl;
|
||||
debugPrint('[UPLOAD] PUT ${res.statusCode} — ${res.body}');
|
||||
} catch (e) {
|
||||
debugPrint('[UPLOAD] PUT failed: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Bumps `attemptcount` rather than failing the consignment — a skip is a
|
||||
/// return visit, not an outcome.
|
||||
static Future<ApiResult> skipConsignment(
|
||||
@@ -926,6 +1091,19 @@ class MilerApi {
|
||||
|
||||
static Future<ApiResult> getStatus() => _send('GET', '/miler/status');
|
||||
|
||||
/// The pickup locations a tenant runs — the counters, branches or kitchens
|
||||
/// the rider collects from.
|
||||
///
|
||||
/// `GET /admin/tenants/:tenantid/locations`. **Note the path is under
|
||||
/// `/admin`, not `/miler`**, which is the one thing worth knowing about it:
|
||||
/// every other route this class calls is on the rider surface, and whether a
|
||||
/// rider's bearer token is accepted here is the backend's decision, not
|
||||
/// ours. A 401 or 403 is therefore an ordinary outcome and not a bug —
|
||||
/// [PickupLocations] treats it as "no names available" and the app carries
|
||||
/// on with whatever the booking row said.
|
||||
static Future<ApiResult> tenantLocations(Object tenantId) =>
|
||||
_send('GET', '/admin/tenants/$tenantId/locations');
|
||||
|
||||
/// A JSON **array**, even for one entry — the handler decodes a list.
|
||||
static Future<ApiResult> postConsignmentLogs(
|
||||
List<ConsignmentLogEntry> entries,
|
||||
@@ -974,12 +1152,20 @@ class MilerApi {
|
||||
/// rather than scattered as `logGap` calls so there is one list to hand the
|
||||
/// backend team. See the write-up in ABOUT_MILER.md §8.
|
||||
static const List<String> missingFromBackend = [
|
||||
'per-stop type (pickup | delivery) and step ordering on GET /miler/bookings',
|
||||
'COD amount on the booking object',
|
||||
'a booking-level resume after skip (skip lives on the consignment only)',
|
||||
// ── Delivered since this list was written, and removed from it ──
|
||||
//
|
||||
// Four of the seven entries here were shipped by the backend and the app
|
||||
// now reads them, so they are gone rather than kept as history:
|
||||
//
|
||||
// per-stop `stoptype` + `step` on GET /miler/bookings → read by
|
||||
// [ApiConfig.adaptBooking]; the mixed route is reachable
|
||||
// `codamount` / `paymentmode` on the booking → read
|
||||
// a booking-level skip → [skipBooking]
|
||||
// `cancelled_stops` / `total_stops` on earnings → read
|
||||
//
|
||||
// What is left is what is still genuinely absent.
|
||||
'a real notifications table with read state',
|
||||
'anything that writes bonuspoints',
|
||||
'cancelled / total counts on GET /miler/earnings',
|
||||
// The quoted fare has nowhere to be recorded: `POST /bookings/:id/payment`
|
||||
// stores what was COLLECTED, and `bookingserviceoptions.estimatedprice` is
|
||||
// written when the customer books and is not rider-writable. So a rider who
|
||||
@@ -989,6 +1175,27 @@ class MilerApi {
|
||||
// A delivery-OTP column on consignments. Until one exists, `deliver`
|
||||
// records whether a code was presented, not whether it was correct.
|
||||
'a real delivery OTP on consignments (deliver cannot verify a code today)',
|
||||
// Proof of delivery is a local file path today: `photourl` wants a URL and
|
||||
// nothing in the contract accepts an upload, so the photograph the rider
|
||||
// takes never leaves his phone. See [ProofStore].
|
||||
'an upload route for the proof-of-delivery photograph',
|
||||
// ── Answered 24 Aug 2026, and off this list ──
|
||||
//
|
||||
// the route sequence → automatic on every assignment; `sequencedat`
|
||||
// is the authority signal and `step` stays on the row through the
|
||||
// pickup leg. `step: 0` with a null stamp now means one of three
|
||||
// stated things, none of which is a route. See [RouteOrder].
|
||||
// a trip / slot id → there is none, and none is planned. The
|
||||
// day-part split is client behaviour by agreement. See [TripSlots].
|
||||
// an upload route → shipped: `POST /miler/uploads/sign`, and the
|
||||
// proof photo reaches the hub. See [uploadProof].
|
||||
//
|
||||
// What is left is what is still genuinely absent.
|
||||
// A rider payout rate. `ridercharges` holds the *client's* order price,
|
||||
// not the rider's pay, and `bonuspoints` is unused — confirmed by the
|
||||
// backend, with a real rate-card named as a separate build. Nothing in
|
||||
// this app may present either figure as what a rider earned.
|
||||
'a rider payout rate — ridercharges is the client price, not rider pay',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user