import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:miler/data/api_config.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE MILER API — every `/miler/*` route, in one place. /// /// Before this existed, endpoints were spelled out inline across five provider /// files, a controller and a widget, each with its own idea of how to decode an /// envelope and what counts as success. Half the contract was implemented and /// the other half was a `logGap` call — and there was nowhere to look to find /// out which half a given route was in. /// /// This is the whole surface: 3 auth routes and 35 authenticated ones. If a /// route is not here, the backend does not have it (see [missingFromBackend]). /// /// ── Things the contract gets wrong if you guess ── /// /// Each of these cost a real debugging session, so they are encoded here rather /// than left to the call site: /// /// • **`configid` is 1001.** It defaults server-side, but a miler row created /// without it can never log in. Always sent. /// • **`verify-pin` has no `data` key.** The login lands under `user` / /// `user.profile`. An earlier contract doc said otherwise and was wrong. /// • **Telemetry numbers are strings.** `latitude`, `longitude`, `speed`, /// `heading`, `battery` on `/logs` and `/consignments/logs` fail to parse if /// sent as JSON numbers. Everywhere else they are numbers. See [_str]. /// • **Never send `userid` in a telemetry body.** Identity comes from the /// token; a body `userid` used to let one rider write another's GPS into the /// dispatch index. It is ignored now, but sending it is still wrong. /// • **The break status is `Break`,** not `On_Break`. /// • **`vehicle-required` reads query strings,** not a body. /// • **`reject` reads its reason from the query string** in the deployed /// handler, while the contract doc says body. Both are sent — see /// [rejectAssignment]. /// • **`/miler/reset-pin` is deliberately absent from this class.** It needs an /// admin token, and it was once open: reset-pin followed by verify-pin took /// over any rider account given only a phone number. Rider PIN resets go /// through ops. Do not add it here. /// ───────────────────────────────────────────────────────────────────────── class MilerApi { MilerApi._(); /// The partition riders live in. static const int configId = 1001; static const Duration _timeout = Duration(seconds: 20); /// Telemetry wants strings for values that are numbers everywhere else. static String _str(dynamic v) => v == null ? '' : v.toString(); /// `YYYY-MM-DD HH:MM:SS`, IST wall-clock, for telemetry `logdate`. static String logStamp([DateTime? at]) { final t = at ?? DateTime.now(); String p(int n) => n.toString().padLeft(2, '0'); return '${t.year}-${p(t.month)}-${p(t.day)} ' '${p(t.hour)}:${p(t.minute)}:${p(t.second)}'; } /// `YYYY-MM-DD`, for date filters. static String dateStamp([DateTime? at]) { final t = at ?? DateTime.now(); String p(int n) => n.toString().padLeft(2, '0'); return '${t.year}-${p(t.month)}-${p(t.day)}'; } // ── Transport ────────────────────────────────────────────────────────── static Future _send( String method, String path, { Object? body, Map? query, bool auth = true, }) async { final uri = Uri.parse( ApiConfig.url(path), ).replace(queryParameters: (query == null || query.isEmpty) ? null : query); final headers = auth ? await ApiConfig.authHeaders() : const { 'Content-Type': 'application/json', 'Accept': 'application/json', }; final encoded = body == null ? null : json.encode(body); try { late http.Response res; switch (method) { case 'GET': res = await http.get(uri, headers: headers).timeout(_timeout); case 'POST': res = await http .post(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PUT': res = await http .put(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PATCH': res = await http .patch(uri, headers: headers, body: encoded) .timeout(_timeout); default: throw ArgumentError('Unsupported method $method'); } dynamic decoded; if (res.body.isNotEmpty) { try { decoded = json.decode(res.body); } catch (_) { decoded = res.body; } } final ok = res.statusCode >= 200 && res.statusCode < 300; // The envelope is `{success, data}` / `{success, message}`. A 2xx with // `success: false` is still a failure — the status line alone is not the // answer. final envelopeOk = decoded is Map ? decoded['success'] != false : true; final result = ApiResult( ok: ok && envelopeOk, status: res.statusCode, data: decoded is Map ? (decoded['data'] ?? decoded) : decoded, raw: decoded, message: decoded is Map ? _str(decoded['message']) : '', ); if (!result.ok) { debugPrint( '[API] $method $path -> ${res.statusCode} ${result.message} ' '${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}', ); } return result; } catch (e) { debugPrint('[API] $method $path -> transport error: $e'); return ApiResult(ok: false, status: 0, message: '$e'); } } // ═══════════════════════════════════════════════════════════════════════ // AUTH (3 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. static Future login(String phone) => _send( 'POST', '/miler/login', auth: false, body: {'phone': phone, 'configid': configId}, ); /// 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 /// this response, whatever the older contract doc claimed. static Future verifyPin({ required String phone, required String pin, String? deviceToken, }) async { final res = await _send( 'POST', '/miler/verify-pin', auth: false, body: { 'phone': phone, 'pin': pin, 'configid': configId, if (deviceToken != null && deviceToken.isNotEmpty) 'device_token': deviceToken, }, ); if (res.ok && res.raw is Map) { final token = _str((res.raw as Map)['token']); if (token.isNotEmpty) await ApiConfig.setToken(token); } return res; } // ═══════════════════════════════════════════════════════════════════════ // PROFILE & DEVICE // ═══════════════════════════════════════════════════════════════════════ static Future getProfile() => _send('GET', '/miler/profile'); static Future updateProfile({ String? displayName, String? profilePhotoUrl, String? defaultVehicleType, String? phone, }) => _send('PUT', '/miler/profile', body: { if (displayName != null) 'displayname': displayName, if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl, if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType, if (phone != null) 'phone': phone, }); /// snake_case, unlike the rest of the contract. static Future setDeviceToken(String token) => _send('PUT', '/miler/device-token', body: {'device_token': token}); // ═══════════════════════════════════════════════════════════════════════ // LOCATION & AVAILABILITY // ═══════════════════════════════════════════════════════════════════════ /// Redis only — a SET plus a GEOADD into `milers:locations`, which is the /// index dispatch searches. Numbers here, not strings; this is not telemetry. static Future pushLocation({ required double latitude, required double longitude, String? pincode, double? speed, double? heading, }) => _send('PUT', '/miler/location', body: { 'latitude': latitude, 'longitude': longitude, if (pincode != null && pincode.isNotEmpty) 'pincode': pincode, if (speed != null) 'speed': speed, if (heading != null) 'heading': heading, }); /// One of [availabilityStatuses]. Sent under both keys the backend has /// accepted at different times, so neither a doc nor a handler change can /// silently drop it. static Future setAvailability(String status) => _send('PUT', '/miler/availability', body: { 'status': status, 'availabilitystatus': status, }); /// Note `Break`, not `On_Break` — the obvious guess is the wrong one. static const List availabilityStatuses = [ 'Offline', 'Available', 'Assigned', 'On_Pickup', 'At_Customer', 'Picked_Up', 'On_Delivery', 'Break', 'Blocked', ]; // ═══════════════════════════════════════════════════════════════════════ // DUTY & BREAKS // // Ordering is enforced server-side: starting duty twice is an error, and a // break without duty is an error. Callers should treat those as state to // reconcile (re-read `dutyCurrent`), not as failures to retry. // ═══════════════════════════════════════════════════════════════════════ static Future startDuty({double? lat, double? lon}) => _send('POST', '/miler/duty/start', body: { 'lat': lat ?? 0, 'lon': lon ?? 0, }); static Future endDuty() => _send('PUT', '/miler/duty/end'); static Future dutyCurrent() => _send('GET', '/miler/duty/current'); static Future startBreak(String breakType) => _send('POST', '/miler/breaks/start', body: { 'breaktype': breakType.isEmpty ? 'Personal' : breakType, }); static Future endBreak() => _send('PUT', '/miler/breaks/end'); // ═══════════════════════════════════════════════════════════════════════ // ASSIGNMENTS // ═══════════════════════════════════════════════════════════════════════ static Future assignments() => _send('GET', '/miler/assignments'); static Future assignment(Object id) => _send('GET', '/miler/assignments/$id'); /// Keyed on `bookingassignmentid`, **not** `bookingid` — they come from /// different sequences. Resolve through `AssignmentLookup` first or the /// backend answers 404 while the rider is shown success. static Future acceptAssignment(Object assignmentId) => _send('POST', '/miler/assignments/$assignmentId/accept', body: {}); /// The deployed handler reads `reason` from the query string; the contract /// doc says the body. Sent both ways — this route has never had a real /// request against it, so neither source is confirmed. static Future rejectAssignment( Object assignmentId, { required String reason, }) => _send( 'POST', '/miler/assignments/$assignmentId/reject', query: {'reason': reason}, body: {'reason': reason}, ); // ═══════════════════════════════════════════════════════════════════════ // THE PICKUP FLOW — in order, all keyed on bookingid // ═══════════════════════════════════════════════════════════════════════ /// Step 1. static Future reached( Object bookingId, { double? lat, double? lon, }) => _send('POST', '/miler/bookings/$bookingId/reached', body: { if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }); /// Step 2. The dimensions here are what `pickup-complete` recomputes /// chargeable weight from, so this is the one moment the parcel's measured /// size can be recorded at all. static Future submitParcels( Object bookingId, List parcels, ) => _send('POST', '/miler/bookings/$bookingId/parcel', body: { 'parcels': [for (final p in parcels) p.toJson()], }); /// Step 3. `amount` must be > 0; `paymentmode` is one of [paymentModes]. static Future submitPayment( Object bookingId, { required double amount, required String paymentMode, String? transactionRef, }) => _send('POST', '/miler/bookings/$bookingId/payment', body: { 'amount': amount, 'paymentmode': paymentMode, 'transactionref': transactionRef ?? '', }); static const List paymentModes = ['Cash', 'UPI', 'Card', 'Wallet']; /// Step 4, and the pivot of the whole flow: it converts the booking into a /// consignment, recomputes chargeable weight from the dimensions submitted in /// step 2, and decides routing — a shared 3-digit pincode prefix means /// hyperlocal and the consignment goes straight to `Out_for_Delivery` in this /// rider's hands, otherwise it routes via the hub. static Future pickupComplete( Object bookingId, { double? lat, double? lon, }) => _send('POST', '/miler/bookings/$bookingId/pickup-complete', body: { if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }); /// Query strings, not a body — the handler reads `c.Query`. static Future vehicleRequired( Object bookingId, { required String type, required String reason, }) => _send( 'POST', '/miler/bookings/$bookingId/vehicle-required', query: {'type': type, 'reason': reason}, ); /// Refused once the booking is picked up. static Future cancelBooking( Object bookingId, { required String reason, }) => _send('POST', '/miler/bookings/$bookingId/cancel', body: { 'reason': reason, }); // ═══════════════════════════════════════════════════════════════════════ // DELIVERY // ═══════════════════════════════════════════════════════════════════════ /// The consignment must be `Out_for_Delivery` or this is a 400. /// /// `otp` is only required when the tenant has `requiredeliveryotp` on — it is /// off by default. When it is on the code is checked server-side, so a /// non-empty string is not enough. /// /// `lat`/`lon` must be the actual delivery point: the server computes /// `riderkms` from the pickup coords by haversine and writes it onto the /// earnings record with `ridercharges`. Passing the pickup coords here would /// silently zero the rider's distance for that leg. static Future deliver( Object consignmentId, { required String deliveredToName, String? otp, String? photoUrl, String? receiverSignatureUrl, double? lat, double? lon, }) => _send('POST', '/miler/consignments/$consignmentId/deliver', body: { 'deliveredtoname': deliveredToName, if (otp != null && otp.isNotEmpty) 'otp': otp, 'photourl': photoUrl ?? '', 'receiversignatureurl': receiverSignatureUrl ?? '', if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }); /// Bumps `attemptcount` rather than failing the consignment — a skip is a /// return visit, not an outcome. static Future skipConsignment( Object consignmentId, { required String reason, double? lat, double? lon, }) => _send('POST', '/miler/consignments/$consignmentId/skip', body: { 'reason': reason, if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }); // ═══════════════════════════════════════════════════════════════════════ // BOOKINGS & EARNINGS // ═══════════════════════════════════════════════════════════════════════ static Future bookings({String? status, String? date}) => _send('GET', '/miler/bookings', query: { if (status != null && status.isNotEmpty) 'status': status, if (date != null && date.isNotEmpty) 'date': date, }); /// `bonuspoints` on this response is always zero — nothing writes it yet. static Future earnings({String period = 'daily', String? date}) => _send('GET', '/miler/earnings', query: { 'period': period, if (date != null && date.isNotEmpty) 'date': date, }); // ═══════════════════════════════════════════════════════════════════════ // TELEMETRY — Redis-backed, high frequency // // Redis is never the system of record here: a flush loses telemetry, not // business state. So every call in this section is best-effort and must // never block the rider. // ═══════════════════════════════════════════════════════════════════════ /// One `MilerLog`. Every numeric field goes as a **string** — sending real /// numbers fails to parse server-side. /// /// Deliberately takes no `userid`: identity comes from the token, and a body /// `userid` is how one rider's GPS could once be written under another's. static Future postLog({ required double latitude, required double longitude, String? status, Object? orderId, double? speed, double? heading, double? accuracy, int? battery, bool isCharging = false, String? connection, String? locationService, bool isBackground = false, DateTime? at, }) => _send('POST', '/miler/logs', body: { 'logdate': logStamp(at), 'latitude': _str(latitude), 'longitude': _str(longitude), if (speed != null) 'speed': _str(speed), 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), if (battery != null) 'battery': _str(battery), 'is_charging': isCharging, if (connection != null) 'connection': connection, if (locationService != null) 'location_service': locationService, 'is_background': isBackground, }); static Future getLogs() => _send('GET', '/miler/logs'); static Future postStatus(String status) => _send('POST', '/miler/status', body: {'status': status}); static Future getStatus() => _send('GET', '/miler/status'); /// A JSON **array**, even for one entry — the handler decodes a list. static Future postConsignmentLogs( List entries, ) => _send( 'POST', '/miler/consignments/logs', body: [for (final e in entries) e.toJson()], ); static Future consignmentLogs(Object consignmentId) => _send('GET', '/miler/consignments/logs/$consignmentId'); /// Must be your own userid; anyone else's is rejected. static Future userLogs(Object userId) => _send('GET', '/miler/consignments/userlogs/$userId'); // ═══════════════════════════════════════════════════════════════════════ // NOTIFICATIONS & SUPPORT // ═══════════════════════════════════════════════════════════════════════ /// Synthesized fresh from `BookingAssignment` rows on every call, and `id` is /// just the array index — so an id is only valid until the next GET. static Future notifications() => _send('GET', '/miler/notifications'); /// **A stub.** Returns success without persisting anything, because there is /// no notifications table with read state. Read state cannot survive a /// refresh until that table exists, so nothing may depend on it. static Future markNotificationRead(Object id) => _send('PATCH', '/miler/notifications/$id/read'); static Future createSupportTicket({ required String subject, required String description, }) => _send('POST', '/miler/support', body: { 'subject': subject, 'description': description, }); static Future supportTickets() => _send('GET', '/miler/support'); // ═══════════════════════════════════════════════════════════════════════ /// What the rider app needs and the backend does not yet expose. Kept here /// 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 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)', 'a real notifications table with read state', 'anything that writes bonuspoints', 'cancelled / total counts on GET /miler/earnings', ]; } /// One parcel measured at the door, for `POST /miler/bookings/:id/parcel`. /// /// 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. class ParcelEntry { final Object? parcelId; final double weight; final double length; final double width; final double height; const ParcelEntry({ this.parcelId, required this.weight, this.length = 0, this.width = 0, this.height = 0, }); Map toJson() => { if (parcelId != null) 'parcel_id': parcelId, 'weight': weight, 'length': length, 'width': width, 'height': height, }; } /// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog]. class ConsignmentLogEntry { final Object consignmentId; final double latitude; final double longitude; final String? status; final double? speed; final double? heading; final int? battery; final String remarks; final bool isBackground; final DateTime? at; const ConsignmentLogEntry({ required this.consignmentId, required this.latitude, required this.longitude, this.status, this.speed, this.heading, this.battery, this.remarks = '', this.isBackground = false, this.at, }); Map toJson() => { 'consignmentid': consignmentId, 'logdate': MilerApi.logStamp(at), 'latitude': latitude.toString(), 'longitude': longitude.toString(), if (speed != null) 'speed': speed.toString(), if (heading != null) 'heading': heading.toString(), if (status != null) 'status': status, 'remarks': remarks, if (battery != null) 'battery': battery.toString(), 'is_background': isBackground, }; } /// The result of one call. /// /// [ok] folds two things the transport keeps apart: a 2xx status *and* an /// envelope that did not say `success: false`. A 200 carrying `success: false` /// is a failure, and treating it as one at the boundary is what stops it being /// re-checked, inconsistently, at every call site. class ApiResult { final bool ok; final int status; /// The envelope's `data`, or the whole body when there is no `data` key. final dynamic data; /// The undecorated decoded body — needed for `verify-pin`, whose payload sits /// under `user` rather than `data`. final dynamic raw; final String message; const ApiResult({ required this.ok, required this.status, this.data, this.raw, this.message = '', }); /// [data] as a map, or an empty one. Map get map => data is Map ? Map.from(data as Map) : {}; /// [data] as a list, tolerating the envelope shapes seen in the wild: /// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`. List get list { dynamic d = data; if (d is List) return d; if (d is Map) { for (final k in const ['items', 'bookings', 'data', 'details', 'logs']) { if (d[k] is List) return d[k] as List; } } return const []; } /// True when the failure is an auth failure — the session is gone and the /// rider has to sign in again, which is a different recovery from a retry. bool get isUnauthorized => status == 401 || status == 403; }