Files
doormile_milderapp/lib/data/miler_api.dart
2026-08-11 13:16:33 +05:30

663 lines
27 KiB
Dart

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<ApiResult> _send(
String method,
String path, {
Object? body,
Map<String, String>? 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<ApiResult> 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<ApiResult> 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<ApiResult> getProfile() => _send('GET', '/miler/profile');
static Future<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<String> 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<ApiResult> startDuty({double? lat, double? lon}) =>
_send('POST', '/miler/duty/start', body: {
'lat': lat ?? 0,
'lon': lon ?? 0,
});
static Future<ApiResult> endDuty() => _send('PUT', '/miler/duty/end');
static Future<ApiResult> dutyCurrent() =>
_send('GET', '/miler/duty/current');
static Future<ApiResult> startBreak(String breakType) =>
_send('POST', '/miler/breaks/start', body: {
'breaktype': breakType.isEmpty ? 'Personal' : breakType,
});
static Future<ApiResult> endBreak() => _send('PUT', '/miler/breaks/end');
// ═══════════════════════════════════════════════════════════════════════
// ASSIGNMENTS
// ═══════════════════════════════════════════════════════════════════════
static Future<ApiResult> assignments() => _send('GET', '/miler/assignments');
static Future<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> submitParcels(
Object bookingId,
List<ParcelEntry> 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<ApiResult> 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<String> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> getLogs() => _send('GET', '/miler/logs');
static Future<ApiResult> postStatus(String status) =>
_send('POST', '/miler/status', body: {'status': status});
static Future<ApiResult> getStatus() => _send('GET', '/miler/status');
/// A JSON **array**, even for one entry — the handler decodes a list.
static Future<ApiResult> postConsignmentLogs(
List<ConsignmentLogEntry> entries,
) => _send(
'POST',
'/miler/consignments/logs',
body: [for (final e in entries) e.toJson()],
);
static Future<ApiResult> consignmentLogs(Object consignmentId) =>
_send('GET', '/miler/consignments/logs/$consignmentId');
/// Must be your own userid; anyone else's is rejected.
static Future<ApiResult> 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<ApiResult> 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<ApiResult> markNotificationRead(Object id) =>
_send('PATCH', '/miler/notifications/$id/read');
static Future<ApiResult> createSupportTicket({
required String subject,
required String description,
}) => _send('POST', '/miler/support', body: {
'subject': subject,
'description': description,
});
static Future<ApiResult> 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<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)',
'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<String, dynamic> 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<String, dynamic> 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<String, dynamic> get map =>
data is Map ? Map<String, dynamic>.from(data as Map) : <String, dynamic>{};
/// [data] as a list, tolerating the envelope shapes seen in the wild:
/// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`.
List<dynamic> 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;
}