production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -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',
];
}