273 lines
9.6 KiB
Dart
273 lines
9.6 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// Base URL, bearer token, and the adapter that turns a v1 booking into the
|
|
/// legacy stop shape the UI reads.
|
|
///
|
|
/// ── The flag is gone ──
|
|
///
|
|
/// This class used to carry `useNewApi`, a `--dart-define` switch between the
|
|
/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`).
|
|
/// Every provider branched on it, so the app shipped two implementations of
|
|
/// every call and only one of them was ever exercised.
|
|
///
|
|
/// It was also the switch that turned the mock layer on: `USE_NEW_API=false`
|
|
/// gave a fake "Demo Rider" login that accepted any four digits, seeded demo
|
|
/// routes, and invented earnings figures. A build flag that silently swaps
|
|
/// authentication for a bypass is not a development convenience.
|
|
///
|
|
/// One backend, one path, no flag. `MilerApi` is where the endpoints live; what
|
|
/// remains here is the base URL, the token, and the booking→stop mapping.
|
|
class ApiConfig {
|
|
ApiConfig._();
|
|
|
|
/// API base URL (no trailing slash).
|
|
static const String newBase = 'https://api.doormile.com/api/v1';
|
|
|
|
static String url(String path) =>
|
|
'$newBase${path.startsWith('/') ? path : '/$path'}';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bearer token
|
|
// ---------------------------------------------------------------------------
|
|
static const String _kTokenKey = 'authtoken';
|
|
|
|
static Future<void> setToken(String token) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kTokenKey, token);
|
|
}
|
|
|
|
static Future<String?> getToken() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final t = prefs.getString(_kTokenKey);
|
|
return (t != null && t.isNotEmpty) ? t : null;
|
|
}
|
|
|
|
static Future<void> clearToken() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_kTokenKey);
|
|
}
|
|
|
|
/// Headers for every authenticated request (Content-Type + Bearer token).
|
|
static Future<Map<String, String>> authHeaders() async {
|
|
final headers = <String, String>{
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
};
|
|
final token = await getToken();
|
|
if (token != null) headers['Authorization'] = 'Bearer $token';
|
|
return headers;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response envelope adapter: {success,data,message} -> {status,details}
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Wrap a decoded new-API response into the legacy envelope the controllers
|
|
/// expect. `_isSuccess()` reads `status` (bool); readers read `details`.
|
|
static Map<String, dynamic> toLegacyEnvelope(dynamic decoded) {
|
|
if (decoded is Map<String, dynamic>) {
|
|
final success = decoded['success'];
|
|
final bool ok = success is bool ? success : success == true;
|
|
return <String, dynamic>{
|
|
'status': ok,
|
|
'code': ok ? 200 : 400,
|
|
'message': decoded['message']?.toString() ?? '',
|
|
'details': decoded['data'] ?? decoded['details'] ?? decoded,
|
|
};
|
|
}
|
|
// Bare list/value response.
|
|
return <String, dynamic>{'status': true, 'code': 200, 'details': decoded};
|
|
}
|
|
|
|
/// A generic "it worked" legacy envelope (for no-op / not-yet-supported flows).
|
|
static Map<String, dynamic> okEnvelope([String message = '']) =>
|
|
<String, dynamic>{'status': true, 'code': 200, 'message': message};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// STATUS mapping (new booking status <-> legacy orderstatus)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// New (in order): Miler_Assigned -> Pickup_Scheduled -> At_Customer
|
|
// -> Picked_Up -> Converted_To_Consignment -> Cancelled
|
|
// Legacy the UI branches on: accepted / active / arrived / "Picked up"
|
|
// / picked / skipped / cancelled / rejected
|
|
|
|
static String legacyStatusFromNew(String? newStatus) {
|
|
switch ((newStatus ?? '').trim()) {
|
|
case 'Miler_Assigned':
|
|
// Admin-assigned but NOT yet accepted by the rider. This must land on
|
|
// the Home tab as a pending booking to accept/reject — it becomes
|
|
// 'accepted' (a client-side marker in accepted_store) only once the
|
|
// rider accepts it, which is what moves it to the Bookings tab.
|
|
return 'assigned';
|
|
case 'Pickup_Scheduled':
|
|
return 'active';
|
|
case 'At_Customer':
|
|
return 'arrived';
|
|
case 'Picked_Up':
|
|
return 'Picked up';
|
|
case 'Converted_To_Consignment':
|
|
return 'picked';
|
|
case 'Cancelled':
|
|
return 'cancelled';
|
|
default:
|
|
return newStatus ?? '';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BOOKING -> legacy pickup object mapping
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The UI reads legacy lowercase keys (pickupid, orderid, orderstatus,
|
|
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
|
|
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
|
|
// object into that shape so the existing cards/flows render unchanged.
|
|
static Map<String, dynamic> pickupFromBooking(Map booking) {
|
|
String s(dynamic v) => v == null ? '' : v.toString();
|
|
// First non-null value among several candidate keys — makes the adapter
|
|
// tolerant of field-name variants (camelCase / snake_case / short forms)
|
|
// so a naming mismatch can't map bookings to empty ids and drop them.
|
|
dynamic pick(List<String> keys) {
|
|
for (final k in keys) {
|
|
final v = booking[k];
|
|
if (v != null && v.toString().trim().isNotEmpty) return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Some endpoints already return the legacy STOP shape the UI reads
|
|
// (orderid/pickupid/orderstatus/pickupcustomer). Translating that as if it
|
|
// were a new-booking object would map everything to empty ids (→ dropped)
|
|
// and lose fields the translation doesn't cover (step, tenant, amounts).
|
|
// Detect it and pass it through untouched.
|
|
final looksNew =
|
|
booking['bookingid'] != null || booking['bookingreference'] != null;
|
|
final looksLegacy =
|
|
booking['orderid'] != null ||
|
|
booking['pickupid'] != null ||
|
|
booking['orderstatus'] != null ||
|
|
booking['pickupcustomer'] != null;
|
|
if (!looksNew && looksLegacy) {
|
|
return Map<String, dynamic>.from(booking);
|
|
}
|
|
|
|
final id = pick(['bookingid', 'bookingId', 'id', 'booking_id', 'pickupid']);
|
|
final ref = pick([
|
|
'bookingreference',
|
|
'bookingRef',
|
|
'reference',
|
|
'referenceno',
|
|
'orderid',
|
|
]);
|
|
final status = pick([
|
|
'status',
|
|
'bookingstatus',
|
|
'booking_status',
|
|
'orderstatus',
|
|
'state',
|
|
]);
|
|
return <String, dynamic>{
|
|
// identity
|
|
'pickupid': id,
|
|
'orderid': ref ?? id,
|
|
'orderheaderid': id,
|
|
'bookingid': id, // keep original too
|
|
'bookingreference': s(ref),
|
|
|
|
// status
|
|
'orderstatus': legacyStatusFromNew(s(status)),
|
|
|
|
// pickup side
|
|
'pickupcustomer': s(
|
|
pick([
|
|
'customername',
|
|
'customerName',
|
|
'customer_name',
|
|
'name',
|
|
'pickupcustomer',
|
|
]),
|
|
),
|
|
'pickupcontactno': s(
|
|
pick([
|
|
'customerphone',
|
|
'customerPhone',
|
|
'customer_phone',
|
|
'phone',
|
|
'pickupcontactno',
|
|
]),
|
|
),
|
|
'pickupaddress': s(
|
|
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
|
|
),
|
|
'pickuplat': s(
|
|
pick([
|
|
'pickuplatitude',
|
|
'pickupLatitude',
|
|
'pickup_latitude',
|
|
'pickuplat',
|
|
]),
|
|
),
|
|
'pickuplong': s(
|
|
pick([
|
|
'pickuplongitude',
|
|
'pickupLongitude',
|
|
'pickup_longitude',
|
|
'pickuplong',
|
|
'pickuplon',
|
|
]),
|
|
),
|
|
'pickuplon': s(
|
|
pick([
|
|
'pickuplongitude',
|
|
'pickupLongitude',
|
|
'pickup_longitude',
|
|
'pickuplong',
|
|
'pickuplon',
|
|
]),
|
|
),
|
|
|
|
// delivery / drop side
|
|
'dropaddress': s(
|
|
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
|
|
),
|
|
'droplat': s(
|
|
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
|
|
),
|
|
'droplon': s(
|
|
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
|
|
),
|
|
|
|
// stop type — new bookings are first-mile PICKUPS; delivery legs come
|
|
// through the consignment flow. Backend has no per-stop `type` yet.
|
|
'type': 'pickup',
|
|
|
|
// money — NOT provided by the new booking object yet (see gaps doc)
|
|
'collectionamt': booking['collectionamt'] ?? 0,
|
|
'pickupamt': booking['pickupamt'] ?? 0,
|
|
|
|
// misc passthrough
|
|
'parcels': booking['parcels'] ?? const [],
|
|
'starttime': s(booking['createdat']),
|
|
'eta': s(booking['eta']),
|
|
};
|
|
}
|
|
|
|
static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) {
|
|
if (data is List) {
|
|
return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList();
|
|
}
|
|
// A single booking object (some endpoints return one, not a list).
|
|
if (data is Map &&
|
|
(data['bookingid'] != null || data['bookingreference'] != null)) {
|
|
return <Map<String, dynamic>>[pickupFromBooking(data)];
|
|
}
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
|
|
static void logGap(String where, String detail) {
|
|
debugPrint('[API_GAP][$where] $detail');
|
|
}
|
|
}
|