663 lines
27 KiB
Dart
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;
|
|
}
|