first commit
This commit is contained in:
311
lib/data/accepted_store.dart
Normal file
311
lib/data/accepted_store.dart
Normal file
@@ -0,0 +1,311 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persistent store of bookings the rider has accepted while the app runs on
|
||||
/// mock data (offline / demo mode). This lets the accept flow be functional
|
||||
/// without a server: the Home queue drops accepted bookings (and keeps them
|
||||
/// dropped across refetches/navigation), and the Bookings tab picks them up.
|
||||
const String _kAcceptedBookingsKey = 'mock_accepted_bookings';
|
||||
const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
|
||||
|
||||
/// Stops finished today, for the Activity tab.
|
||||
///
|
||||
/// ── Why a local store and not just the API ──
|
||||
///
|
||||
/// Finishing a stop is optimistic everywhere in this app: the confirm sheet
|
||||
/// records the completion and moves the rider on whether or not the status call
|
||||
/// succeeded, because a rider who watches a completed stop bounce back stops
|
||||
/// trusting the button. That leaves the completion existing *only* in the
|
||||
/// Bookings tab's in-memory state — it is dropped from the working list and
|
||||
/// removed from the accepted store, and nothing else remembers it.
|
||||
///
|
||||
/// So Activity, which reads the queue endpoints, showed nothing at all: on demo
|
||||
/// data the backend never returns the finished stop, and on live data it does
|
||||
/// not return it until the write lands and the next poll comes round. The
|
||||
/// rider finished a booking and the tab meant to prove it stayed empty.
|
||||
///
|
||||
/// This is the record that survives that. It is the same trade the accepted
|
||||
/// store already makes, for the same reason.
|
||||
const String _kCompletedBookingsKey = 'completed_bookings';
|
||||
|
||||
/// Stops the rider parked mid-shift for a return visit.
|
||||
///
|
||||
/// ── Why these are stored at all ──
|
||||
///
|
||||
/// A skip used to live in one place only: `_MyPickupsState._skippedOrdersCache`,
|
||||
/// a static map in the Bookings screen's State. That was enough while the skip
|
||||
/// was displayed on Home, because Home is rebuilt from the same queue the cache
|
||||
/// re-stamps. It is not enough now that a skipped stop is shown on **Activity**,
|
||||
/// which reads its own endpoints: on demo data the backend never reports the
|
||||
/// skip at all, and on live data it does not report it until the write lands.
|
||||
/// The rider would skip a stop and find the tab that is meant to account for it
|
||||
/// empty — the same failure the completed store was written to fix.
|
||||
///
|
||||
/// So a skip is recorded here the moment it is taken, with the reason the rider
|
||||
/// gave, and it is removed when he resumes the stop. Same trade as
|
||||
/// [_kCompletedBookingsKey], for the same reason.
|
||||
const String _kSkippedBookingsKey = 'skipped_bookings';
|
||||
|
||||
List<Map<String, dynamic>> _decode(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// All bookings that have been locally accepted (each carries
|
||||
/// `orderstatus: 'accepted'`).
|
||||
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _decode(prefs.getString(_kAcceptedBookingsKey));
|
||||
}
|
||||
|
||||
/// The set of order ids that have been locally accepted.
|
||||
Future<Set<String>> getAcceptedOrderIds() async {
|
||||
final list = await getAcceptedBookings();
|
||||
return list
|
||||
.map((b) => (b['orderid'] ?? '').toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Order ids the rider has locally rejected. Home filters these out so a
|
||||
/// rejected booking leaves the pending list.
|
||||
Future<Set<String>> getRejectedOrderIds() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
|
||||
}
|
||||
|
||||
/// Remember the given order ids as rejected.
|
||||
Future<void> addRejectedOrderIds(List<String> ids) async {
|
||||
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
||||
if (clean.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
|
||||
existing.addAll(clean);
|
||||
await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList());
|
||||
}
|
||||
|
||||
/// Undo a rejection.
|
||||
///
|
||||
/// Rejecting is one tap on a moving bike, so it is genuinely easy to hit the
|
||||
/// wrong stop. Without this the mistake is permanent for the rest of the shift
|
||||
/// and the rider has to call the hub to fix it.
|
||||
Future<void> removeRejectedOrderIds(List<String> ids) async {
|
||||
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||
if (drop.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining = (prefs.getStringList(_kRejectedOrderIdsKey) ?? [])
|
||||
.where((id) => !drop.contains(id))
|
||||
.toList();
|
||||
await prefs.setStringList(_kRejectedOrderIdsKey, remaining);
|
||||
}
|
||||
|
||||
/// Remove the given order ids from the accepted store. Called when a pickup is
|
||||
/// completed or cancelled so it stops counting as an open booking (and no longer
|
||||
/// reappears on the Bookings tab or as a "next stop" after a rebuild).
|
||||
Future<void> removeAcceptedBookings(List<String> ids) async {
|
||||
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||
if (drop.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining = _decode(
|
||||
prefs.getString(_kAcceptedBookingsKey),
|
||||
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
||||
await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining));
|
||||
}
|
||||
|
||||
/// Stops the rider finished **today**, newest first.
|
||||
///
|
||||
/// Entries from earlier days are dropped on read rather than on a timer, so a
|
||||
/// shift that runs past midnight keeps its stops until the rider next opens the
|
||||
/// tab — and the store cannot grow without bound.
|
||||
Future<List<Map<String, dynamic>>> getCompletedBookings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final all = _decode(prefs.getString(_kCompletedBookingsKey));
|
||||
final today = _dayStamp(DateTime.now());
|
||||
|
||||
final mine = all.where((b) => (b['completedday'] ?? '') == today).toList()
|
||||
..sort(
|
||||
(a, b) => (b['completedat'] ?? '').toString().compareTo(
|
||||
(a['completedat'] ?? '').toString(),
|
||||
),
|
||||
);
|
||||
|
||||
// Prune in the background if yesterday's rows are still in there.
|
||||
if (mine.length != all.length) {
|
||||
await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine));
|
||||
}
|
||||
return mine;
|
||||
}
|
||||
|
||||
/// Prefs key holding the moment the rider opened a stop and set off for it.
|
||||
/// Written by the stop map screen, read and cleared here. See [kStopStartedKey].
|
||||
String kStopStartedKey(Object pickupId) => 'stop_started_$pickupId';
|
||||
|
||||
/// Prefs key holding the moment the rider confirmed he was at the door.
|
||||
/// Written by the stop map screen when it starts the pickup.
|
||||
String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId';
|
||||
|
||||
/// Record a finished stop. [cancelled] separates "could not complete" from a
|
||||
/// clean pickup/delivery — Activity shows both, worded differently.
|
||||
///
|
||||
/// ── Why the timings are collected here ──
|
||||
///
|
||||
/// A finished stop is a record, and the questions asked of it afterwards are
|
||||
/// all about *time*: when did he set off, when did he get there, how long did
|
||||
/// the door take, did he beat the ETA. Every one of those was being measured
|
||||
/// somewhere in the app and then dropped on the floor — the two timestamps live
|
||||
/// in SharedPreferences keys that the next stop overwrites, so by the time
|
||||
/// anyone opened Activity they were gone or, worse, belonged to a different
|
||||
/// stop.
|
||||
///
|
||||
/// This is the last moment they are all true at once, so this is where they are
|
||||
/// written down and the keys are cleared. Same argument as [StopCompliance],
|
||||
/// which is stamped one call earlier for the same reason.
|
||||
Future<void> addCompletedBookings(
|
||||
List<Map<String, dynamic>> bookings, {
|
||||
bool cancelled = false,
|
||||
}) async {
|
||||
if (bookings.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final now = DateTime.now();
|
||||
|
||||
final Map<String, Map<String, dynamic>> byId = {
|
||||
for (final b in _decode(prefs.getString(_kCompletedBookingsKey)))
|
||||
(b['orderid'] ?? '').toString(): b,
|
||||
};
|
||||
|
||||
for (final b in bookings) {
|
||||
final id = (b['orderid'] ?? '').toString();
|
||||
if (id.isEmpty) continue;
|
||||
final copy = Map<String, dynamic>.from(b);
|
||||
// Stamped so the same `stopStatusOf` test that drops a stop from Bookings
|
||||
// is the one that picks it up here — the two can never disagree about what
|
||||
// "done" means.
|
||||
copy['orderstatus'] = cancelled ? 'cancelled' : 'picked';
|
||||
copy['completedat'] = now.toIso8601String();
|
||||
copy['completedday'] = _dayStamp(now);
|
||||
|
||||
final pickupId = (b['pickupid'] ?? '').toString();
|
||||
if (pickupId.isNotEmpty) {
|
||||
final started = prefs.getString(kStopStartedKey(pickupId));
|
||||
final arrived = prefs.getString(kStopArrivedKey(pickupId));
|
||||
if (started != null && started.isNotEmpty) copy['startedat'] = started;
|
||||
if (arrived != null && arrived.isNotEmpty) copy['arrivedat'] = arrived;
|
||||
// Cleared, or a stop worked twice in a day (a resumed skip) reports the
|
||||
// first attempt's clock against the second attempt's completion.
|
||||
await prefs.remove(kStopStartedKey(pickupId));
|
||||
await prefs.remove(kStopArrivedKey(pickupId));
|
||||
}
|
||||
|
||||
byId[id] = copy;
|
||||
}
|
||||
|
||||
await prefs.setString(
|
||||
_kCompletedBookingsKey,
|
||||
jsonEncode(byId.values.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops skipped **today**, newest first.
|
||||
///
|
||||
/// Pruned on read like [getCompletedBookings]: a skip is a "come back to it
|
||||
/// this shift" marker, and one left over from yesterday is noise the rider can
|
||||
/// no longer act on.
|
||||
Future<List<Map<String, dynamic>>> getSkippedBookings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final all = _decode(prefs.getString(_kSkippedBookingsKey));
|
||||
final today = _dayStamp(DateTime.now());
|
||||
|
||||
final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList()
|
||||
..sort(
|
||||
(a, b) => (b['skippedat'] ?? '').toString().compareTo(
|
||||
(a['skippedat'] ?? '').toString(),
|
||||
),
|
||||
);
|
||||
|
||||
if (mine.length != all.length) {
|
||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine));
|
||||
}
|
||||
return mine;
|
||||
}
|
||||
|
||||
/// The set of order ids currently parked as skipped.
|
||||
Future<Set<String>> getSkippedOrderIds() async {
|
||||
final list = await getSkippedBookings();
|
||||
return list
|
||||
.map((b) => (b['orderid'] ?? '').toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Record a skip, with the reason the rider picked in the skip sheet.
|
||||
Future<void> addSkippedBooking(
|
||||
Map<String, dynamic> booking, {
|
||||
String reason = '',
|
||||
}) async {
|
||||
final id = (booking['orderid'] ?? '').toString();
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final now = DateTime.now();
|
||||
|
||||
final Map<String, Map<String, dynamic>> byId = {
|
||||
for (final b in _decode(prefs.getString(_kSkippedBookingsKey)))
|
||||
(b['orderid'] ?? '').toString(): b,
|
||||
};
|
||||
|
||||
final copy = Map<String, dynamic>.from(booking);
|
||||
// Stamped so `stopStatusOf` reads it as skipped wherever it surfaces — the
|
||||
// same trick the completed store uses, so one status test serves both.
|
||||
copy['orderstatus'] = 'skipped';
|
||||
copy['skipreason'] = reason;
|
||||
copy['skippedat'] = now.toIso8601String();
|
||||
copy['skippedday'] = _dayStamp(now);
|
||||
byId[id] = copy;
|
||||
|
||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList()));
|
||||
}
|
||||
|
||||
/// Forget a skip — the rider resumed the stop, so it is live work again.
|
||||
Future<void> removeSkippedBookings(List<String> ids) async {
|
||||
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||
if (drop.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining = _decode(
|
||||
prefs.getString(_kSkippedBookingsKey),
|
||||
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining));
|
||||
}
|
||||
|
||||
String _dayStamp(DateTime t) =>
|
||||
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
|
||||
'${t.day.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Persist the given bookings as accepted, deduped by `orderid`.
|
||||
Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
|
||||
if (bookings.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final Map<String, Map<String, dynamic>> byId = {
|
||||
for (final b in _decode(prefs.getString(_kAcceptedBookingsKey)))
|
||||
(b['orderid'] ?? '').toString(): b,
|
||||
};
|
||||
for (final b in bookings) {
|
||||
final id = (b['orderid'] ?? '').toString();
|
||||
if (id.isEmpty) continue;
|
||||
final copy = Map<String, dynamic>.from(b);
|
||||
copy['orderstatus'] = 'accepted';
|
||||
byId[id] = copy;
|
||||
}
|
||||
await prefs.setString(
|
||||
_kAcceptedBookingsKey,
|
||||
jsonEncode(byId.values.toList()),
|
||||
);
|
||||
}
|
||||
272
lib/data/api_config.dart
Normal file
272
lib/data/api_config.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
139
lib/data/assignment_lookup.dart
Normal file
139
lib/data/assignment_lookup.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:miler/data/api_config.dart';
|
||||
|
||||
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
|
||||
/// endpoints key on.
|
||||
///
|
||||
/// Why this exists: `POST /miler/assignments/{id}/accept` looks the row up by
|
||||
/// `bookingassignmentid`, but everything the app renders comes from
|
||||
/// `GET /miler/bookings`, whose rows only carry `bookingid`. Those two ids come
|
||||
/// from different sequences and do not match, so sending a booking id makes the
|
||||
/// backend answer 404 "assignment not found" — the rider taps Accept, sees
|
||||
/// success, and the booking is never actually accepted server-side.
|
||||
///
|
||||
/// `GET /miler/assignments` is the only place the pairing is exposed, so we
|
||||
/// fetch it and keep a short-lived bookingid -> bookingassignmentid map.
|
||||
class AssignmentLookup {
|
||||
AssignmentLookup._();
|
||||
|
||||
/// bookingid (as string) -> bookingassignmentid
|
||||
static final Map<String, int> _cache = <String, int>{};
|
||||
static DateTime? _fetchedAt;
|
||||
|
||||
/// Assignments change whenever the hub assigns work, so the map goes stale
|
||||
/// quickly. Short enough to pick up new work, long enough that a burst of
|
||||
/// accepts doesn't refetch per tap.
|
||||
static const Duration _ttl = Duration(seconds: 30);
|
||||
|
||||
/// Assignment states that are still actionable by the rider. A booking can
|
||||
/// legitimately have several assignment rows over its life (assign → reject →
|
||||
/// reassign), so when more than one exists we want the live one, not the
|
||||
/// rejected corpse.
|
||||
static const Set<String> _actionable = {'Assigned', 'Accepted'};
|
||||
|
||||
static bool get _isStale {
|
||||
final at = _fetchedAt;
|
||||
return at == null || DateTime.now().difference(at) > _ttl;
|
||||
}
|
||||
|
||||
static void invalidate() {
|
||||
_cache.clear();
|
||||
_fetchedAt = null;
|
||||
}
|
||||
|
||||
/// The assignment id for [bookingId], or null when the backend has no
|
||||
/// assignment row for it (or the call fails).
|
||||
///
|
||||
/// Refetches once on a cache miss before giving up: a booking the hub just
|
||||
/// assigned will not be in a cache populated moments earlier, and that is
|
||||
/// exactly the case the rider hits when accepting fresh work.
|
||||
static Future<int?> idForBooking(dynamic bookingId) async {
|
||||
final String key = bookingId?.toString().trim() ?? '';
|
||||
if (key.isEmpty) return null;
|
||||
|
||||
if (_isStale) {
|
||||
await _refresh();
|
||||
}
|
||||
final cached = _cache[key];
|
||||
if (cached != null) return cached;
|
||||
|
||||
// Miss against a warm cache — the assignment may have been created since.
|
||||
await _refresh();
|
||||
return _cache[key];
|
||||
}
|
||||
|
||||
static Future<void> _refresh() async {
|
||||
try {
|
||||
final uri = Uri.parse(ApiConfig.url('/miler/assignments'));
|
||||
final res = await http
|
||||
.get(uri, headers: await ApiConfig.authHeaders())
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.statusCode}');
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = json.decode(res.body);
|
||||
dynamic data = decoded;
|
||||
if (decoded is Map) {
|
||||
data = decoded['data'] ?? decoded['details'] ?? decoded['assignments'];
|
||||
}
|
||||
if (data is! List) {
|
||||
debugPrint('[ASSIGNMENTS] unexpected payload: ${data.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final next = buildIndex(data);
|
||||
_cache
|
||||
..clear()
|
||||
..addAll(next);
|
||||
_fetchedAt = DateTime.now();
|
||||
debugPrint(
|
||||
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ASSIGNMENTS] fetch error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses the assignment rows into one bookingid -> bookingassignmentid
|
||||
/// entry per booking.
|
||||
///
|
||||
/// The backend orders by `assignedat DESC`, so the first row seen for a
|
||||
/// booking is its newest. Keep that one, but let an actionable row override a
|
||||
/// newer terminal one — a booking that was assigned, rejected, then
|
||||
/// reassigned must resolve to the live assignment, not the rejected corpse.
|
||||
@visibleForTesting
|
||||
static Map<String, int> buildIndex(List<dynamic> rows) {
|
||||
final index = <String, int>{};
|
||||
final tookActionable = <String>{};
|
||||
|
||||
for (final row in rows.whereType<Map>()) {
|
||||
final bookingKey = row['bookingid']?.toString().trim() ?? '';
|
||||
final assignmentId = _asInt(row['bookingassignmentid']);
|
||||
if (bookingKey.isEmpty || assignmentId == null) continue;
|
||||
|
||||
final status = (row['assignmentstatus'] ?? '').toString().trim();
|
||||
final isActionable = _actionable.contains(status);
|
||||
|
||||
if (!index.containsKey(bookingKey)) {
|
||||
index[bookingKey] = assignmentId;
|
||||
if (isActionable) tookActionable.add(bookingKey);
|
||||
} else if (isActionable && !tookActionable.contains(bookingKey)) {
|
||||
index[bookingKey] = assignmentId;
|
||||
tookActionable.add(bookingKey);
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
static int? _asInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
662
lib/data/miler_api.dart
Normal file
662
lib/data/miler_api.dart
Normal file
@@ -0,0 +1,662 @@
|
||||
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;
|
||||
}
|
||||
172
lib/data/rider_tier.dart
Normal file
172
lib/data/rider_tier.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// RIDER STATUS — the tier ladder the Rewards page is built around
|
||||
///
|
||||
/// Modelled on Uber Pro, which is the version of this a rider is most likely to
|
||||
/// have already used: four named statuses, points earned per completed job over
|
||||
/// a fixed reward period, and quality standards that gate the higher tiers
|
||||
/// regardless of volume. Every loyalty-UX source says the same three things
|
||||
/// belong on the screen before anything else — where you stand, what you have,
|
||||
/// and exactly how far the next step is — so those are the three this model has
|
||||
/// to be able to answer.
|
||||
///
|
||||
/// ── What is real here and what the business still owns ──
|
||||
///
|
||||
/// The *points* are real: the backend already awards them per stop closed
|
||||
/// inside its ETA, scaled by the distance ridden, and `RewardsController` reads
|
||||
/// that total. The *standards* are real: on-time and on-route are measured per
|
||||
/// stop (see `StopCompliance`).
|
||||
///
|
||||
/// The **thresholds below are placeholders**, and so is the idea that a tier
|
||||
/// unlocks anything. Where the bar sits, and what a rider gets for clearing it,
|
||||
/// are commercial decisions belonging to the hub — not to this file. They live
|
||||
/// in one place, named, so a real number drops straight in. Nothing in the UI
|
||||
/// invents a perk: the page states status, progress and standards, and says
|
||||
/// plainly that the hub sets what each status is worth.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
enum RiderTier { blue, silver, gold, platinum }
|
||||
|
||||
/// Points needed to reach each tier within one reward period.
|
||||
///
|
||||
/// PLACEHOLDER — replace with the hub's real ladder. Kept as a single map so
|
||||
/// the whole ladder can be swapped without touching the page.
|
||||
const Map<RiderTier, int> kTierThresholds = {
|
||||
RiderTier.blue: 0,
|
||||
RiderTier.silver: 150,
|
||||
RiderTier.gold: 400,
|
||||
RiderTier.platinum: 800,
|
||||
};
|
||||
|
||||
/// The share of measured stops that must clear a check for the status to hold.
|
||||
///
|
||||
/// PLACEHOLDER, same as above. Uber gates its tiers on acceptance and
|
||||
/// cancellation rates; the equivalents here are the two the hub actually
|
||||
/// measures — did he make the ETA, did he ride the route.
|
||||
const int kStandardTarget = 80;
|
||||
|
||||
extension RiderTierUi on RiderTier {
|
||||
String get label => switch (this) {
|
||||
RiderTier.blue => 'Blue',
|
||||
RiderTier.silver => 'Silver',
|
||||
RiderTier.gold => 'Gold',
|
||||
RiderTier.platinum => 'Platinum',
|
||||
};
|
||||
|
||||
/// The tier above this one, or null at the top.
|
||||
RiderTier? get next => switch (this) {
|
||||
RiderTier.blue => RiderTier.silver,
|
||||
RiderTier.silver => RiderTier.gold,
|
||||
RiderTier.gold => RiderTier.platinum,
|
||||
RiderTier.platinum => null,
|
||||
};
|
||||
|
||||
int get threshold => kTierThresholds[this] ?? 0;
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
RiderTier.blue => Icons.pedal_bike_rounded,
|
||||
RiderTier.silver => Icons.military_tech_rounded,
|
||||
RiderTier.gold => Icons.workspace_premium_rounded,
|
||||
RiderTier.platinum => Icons.diamond_rounded,
|
||||
};
|
||||
|
||||
/// Colour of the badge, not of the page. The card behind it stays brand
|
||||
/// maroon at every tier — a status screen that changes colour scheme as the
|
||||
/// rider moves up stops looking like the same product.
|
||||
Color get accent => switch (this) {
|
||||
RiderTier.blue => const Color(0xFF7FB3D5),
|
||||
RiderTier.silver => const Color(0xFFC0C6CC),
|
||||
RiderTier.gold => ColorConstants.rewardAmber,
|
||||
RiderTier.platinum => const Color(0xFFB9F2FF),
|
||||
};
|
||||
}
|
||||
|
||||
/// Where a rider stands this period, and how far the next step is.
|
||||
class TierStanding {
|
||||
final int points;
|
||||
final RiderTier tier;
|
||||
|
||||
const TierStanding({required this.points, required this.tier});
|
||||
|
||||
static TierStanding of(int points) {
|
||||
var reached = RiderTier.blue;
|
||||
for (final tier in RiderTier.values) {
|
||||
if (points >= tier.threshold) reached = tier;
|
||||
}
|
||||
return TierStanding(points: points, tier: reached);
|
||||
}
|
||||
|
||||
RiderTier? get nextTier => tier.next;
|
||||
|
||||
/// Points still needed for the next status. Null at the top.
|
||||
int? get pointsToNext {
|
||||
final next = nextTier;
|
||||
if (next == null) return null;
|
||||
final gap = next.threshold - points;
|
||||
return gap <= 0 ? 0 : gap;
|
||||
}
|
||||
|
||||
/// 0–1 across the current band, for the progress bar.
|
||||
///
|
||||
/// Measured from *this* tier's threshold rather than from zero: a rider one
|
||||
/// point into Gold should see an empty bar to Platinum, not a bar that is
|
||||
/// already half full because he has a big lifetime number.
|
||||
double get progressToNext {
|
||||
final next = nextTier;
|
||||
if (next == null) return 1;
|
||||
final floor = tier.threshold;
|
||||
final span = next.threshold - floor;
|
||||
if (span <= 0) return 1;
|
||||
return ((points - floor) / span).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reward period: the calendar month.
|
||||
///
|
||||
/// Uber runs three-month periods. A month is the right length here because a
|
||||
/// miler's shift patterns and the hub's rosters are monthly, and because a
|
||||
/// three-month bar is one a rider cannot move in a day — the whole point of
|
||||
/// showing progress is that today's work visibly changes it.
|
||||
class RewardPeriod {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
const RewardPeriod({required this.start, required this.end});
|
||||
|
||||
static RewardPeriod current([DateTime? now]) {
|
||||
final t = now ?? DateTime.now();
|
||||
return RewardPeriod(
|
||||
start: DateTime(t.year, t.month, 1),
|
||||
// Day zero of next month is the last day of this one.
|
||||
end: DateTime(t.year, t.month + 1, 0),
|
||||
);
|
||||
}
|
||||
|
||||
static const List<String> _months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
String get label => '${_months[start.month - 1]} ${start.year}';
|
||||
|
||||
/// Whole days left, today included. Never negative.
|
||||
int daysLeft([DateTime? now]) {
|
||||
final t = now ?? DateTime.now();
|
||||
final today = DateTime(t.year, t.month, t.day);
|
||||
final last = DateTime(end.year, end.month, end.day);
|
||||
final diff = last.difference(today).inDays + 1;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
}
|
||||
210
lib/data/stop_compliance.dart
Normal file
210
lib/data/stop_compliance.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// STOP COMPLIANCE — did the rider make the ETA, and did he take the route?
|
||||
///
|
||||
/// Both facts were already being computed and then thrown away. The bonus-point
|
||||
/// rule in `PickupsController.updatePickedupStatus` is literally "was this
|
||||
/// closed before `eta_endtime`", and the distance travelled to the stop is
|
||||
/// measured to bill rider charges. Neither ever reached the rider: he was paid
|
||||
/// (or not) for punctuality he could not see, and "did I go the way the hub
|
||||
/// routed me" had no answer anywhere in the app.
|
||||
///
|
||||
/// This is that pair, stamped onto the stop when it is completed and read back
|
||||
/// on the Activity tab and in Rewards.
|
||||
///
|
||||
/// ── Why it is stamped and not recomputed ──
|
||||
///
|
||||
/// The inputs are gone by the time anyone looks: the ETA deadline lives in a
|
||||
/// SharedPreferences key that the next stop overwrites, and the distance is
|
||||
/// measured from the previous stop's location, which has also moved on. The
|
||||
/// only moment both are true is the moment of completion, so that is where the
|
||||
/// record is written.
|
||||
///
|
||||
/// ── Unknown is a real answer ──
|
||||
///
|
||||
/// A hub that assigns no ETA, a stop whose GPS never resolved: those are not
|
||||
/// failures by the rider and are never shown as one. Every field is nullable
|
||||
/// and the UI says "not measured" rather than inventing a verdict — a
|
||||
/// compliance score that quietly counts unknowns as misses is a score that
|
||||
/// punishes riders for the back office's gaps.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
library;
|
||||
|
||||
/// How far past the planned distance still counts as "took the route".
|
||||
///
|
||||
/// A route figure is a straight-line-ish estimate from the hub's planner, and a
|
||||
/// rider obeys one-ways, diversions and closed roads it knows nothing about. A
|
||||
/// quarter over plus half a kilometre of slack is the difference between "took
|
||||
/// a detour" and "went somewhere else" — tight enough to catch a genuine
|
||||
/// wander, loose enough that ordinary traffic never trips it.
|
||||
const double kRouteTolerance = 1.25;
|
||||
const double kRouteGraceKm = 0.5;
|
||||
|
||||
class StopCompliance {
|
||||
/// Closed before the ETA deadline. Null when no ETA was ever set.
|
||||
final bool? onTime;
|
||||
|
||||
/// How late, when [onTime] is false.
|
||||
final Duration? lateBy;
|
||||
|
||||
/// Distance actually ridden to this stop, and what the hub planned.
|
||||
final double? actualKm;
|
||||
final double? plannedKm;
|
||||
|
||||
const StopCompliance({
|
||||
this.onTime,
|
||||
this.lateBy,
|
||||
this.actualKm,
|
||||
this.plannedKm,
|
||||
});
|
||||
|
||||
static const StopCompliance unknown = StopCompliance();
|
||||
|
||||
/// Took the assigned route, within [kRouteTolerance]. Null when either
|
||||
/// distance is missing — a planned figure of zero is missing, not perfect.
|
||||
bool? get onRoute {
|
||||
final actual = actualKm;
|
||||
final planned = plannedKm;
|
||||
if (actual == null || planned == null || planned <= 0 || actual <= 0) {
|
||||
return null;
|
||||
}
|
||||
return actual <= (planned * kRouteTolerance) + kRouteGraceKm;
|
||||
}
|
||||
|
||||
/// Kilometres beyond what the route allowed for. Null unless [onRoute] is
|
||||
/// false — there is no "negative detour" worth reporting.
|
||||
double? get extraKm {
|
||||
if (onRoute != false) return null;
|
||||
return (actualKm! - plannedKm!).clamp(0, double.infinity).toDouble();
|
||||
}
|
||||
|
||||
/// True only when both halves are known and both are met. This is the one
|
||||
/// that earns the reward.
|
||||
bool get isClean => onTime == true && onRoute == true;
|
||||
|
||||
/// Nothing was measurable for this stop.
|
||||
bool get isUnknown => onTime == null && onRoute == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (onTime != null) 'ontime': onTime,
|
||||
if (lateBy != null) 'latebyseconds': lateBy!.inSeconds,
|
||||
if (actualKm != null) 'actualkm': actualKm,
|
||||
if (plannedKm != null) 'plannedkm': plannedKm,
|
||||
};
|
||||
|
||||
/// Reads the compliance of a finished stop.
|
||||
///
|
||||
/// Prefers the stamp written at completion; falls back to whatever the
|
||||
/// backend's own row carries, so a stop completed before this existed — or on
|
||||
/// another device — still says what it can.
|
||||
static StopCompliance of(Map<String, dynamic> stop) {
|
||||
final raw = stop['compliance'];
|
||||
final Map<String, dynamic> m = raw is Map
|
||||
? Map<String, dynamic>.from(raw)
|
||||
: const <String, dynamic>{};
|
||||
|
||||
final bool? onTime = _bool(m['ontime']);
|
||||
final int? lateSeconds = _int(m['latebyseconds']);
|
||||
|
||||
return StopCompliance(
|
||||
onTime: onTime,
|
||||
lateBy: lateSeconds == null ? null : Duration(seconds: lateSeconds),
|
||||
// `riderkms` is the backend's own name for the distance ridden, so a row
|
||||
// that came straight from the API reads without a stamp.
|
||||
actualKm: _double(m['actualkm']) ?? _double(stop['riderkms']),
|
||||
plannedKm: _double(m['plannedkm']) ?? _double(stop['kms']),
|
||||
);
|
||||
}
|
||||
|
||||
static bool? _bool(dynamic v) {
|
||||
if (v is bool) return v;
|
||||
if (v is num) return v != 0;
|
||||
final s = v?.toString().toLowerCase().trim();
|
||||
if (s == 'true' || s == '1') return true;
|
||||
if (s == 'false' || s == '0') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _int(dynamic v) {
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v?.toString().trim() ?? '');
|
||||
}
|
||||
|
||||
static double? _double(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is num) return v.toDouble();
|
||||
final parsed = double.tryParse(
|
||||
v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''),
|
||||
);
|
||||
if (parsed == null || parsed == 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's compliance, for the Rewards page.
|
||||
class ComplianceSummary {
|
||||
/// Stops that could be judged at all — the denominator.
|
||||
final int measured;
|
||||
final int onTime;
|
||||
final int onRoute;
|
||||
|
||||
/// Stops where both held. This is what the reward is paid on.
|
||||
final int clean;
|
||||
|
||||
/// Finished stops with nothing measurable, kept separate so they are never
|
||||
/// silently counted as misses.
|
||||
final int unmeasured;
|
||||
|
||||
const ComplianceSummary({
|
||||
required this.measured,
|
||||
required this.onTime,
|
||||
required this.onRoute,
|
||||
required this.clean,
|
||||
required this.unmeasured,
|
||||
});
|
||||
|
||||
static const ComplianceSummary empty = ComplianceSummary(
|
||||
measured: 0,
|
||||
onTime: 0,
|
||||
onRoute: 0,
|
||||
clean: 0,
|
||||
unmeasured: 0,
|
||||
);
|
||||
|
||||
int get onTimePercent =>
|
||||
measured == 0 ? 0 : ((onTime / measured) * 100).round();
|
||||
int get onRoutePercent =>
|
||||
measured == 0 ? 0 : ((onRoute / measured) * 100).round();
|
||||
int get cleanPercent =>
|
||||
measured == 0 ? 0 : ((clean / measured) * 100).round();
|
||||
|
||||
/// Points earned: one per stop that made both its ETA and its route.
|
||||
///
|
||||
/// Deliberately a count and not a percentage — a rider who did four clean
|
||||
/// stops out of four has not earned the same as one who did twelve, and a
|
||||
/// percentage would say he had.
|
||||
int get points => clean;
|
||||
|
||||
static ComplianceSummary from(Iterable<Map<String, dynamic>> stops) {
|
||||
int measured = 0, onTime = 0, onRoute = 0, clean = 0, unmeasured = 0;
|
||||
|
||||
for (final stop in stops) {
|
||||
final c = StopCompliance.of(stop);
|
||||
if (c.isUnknown) {
|
||||
unmeasured++;
|
||||
continue;
|
||||
}
|
||||
measured++;
|
||||
if (c.onTime == true) onTime++;
|
||||
if (c.onRoute == true) onRoute++;
|
||||
if (c.isClean) clean++;
|
||||
}
|
||||
|
||||
return ComplianceSummary(
|
||||
measured: measured,
|
||||
onTime: onTime,
|
||||
onRoute: onRoute,
|
||||
clean: clean,
|
||||
unmeasured: unmeasured,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user