Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,11 +62,7 @@ Future<Map<String, dynamic>?> _startDuty(Map data) async {
|
||||
return {
|
||||
'status': true,
|
||||
'code': 200,
|
||||
'details': {
|
||||
'logid': id,
|
||||
'login': res.map['loginat'],
|
||||
'onduty': 1,
|
||||
},
|
||||
'details': {'logid': id, 'login': res.map['loginat'], 'onduty': 1},
|
||||
};
|
||||
}
|
||||
// "Already on duty" is a 400 and is not a failure — it is the app and the
|
||||
@@ -133,7 +129,9 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
||||
),
|
||||
);
|
||||
|
||||
return res.ok ? ApiConfig.okEnvelope() : {'status': false, 'code': res.status};
|
||||
return res.ok
|
||||
? ApiConfig.okEnvelope()
|
||||
: {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
/// True when the payload means "go off duty".
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -368,7 +368,7 @@ class NotificationServce {
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/work_repository.dart';
|
||||
import 'package:miler/data/load_state.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// Reads the rider's bookings.
|
||||
@@ -26,25 +26,56 @@ class PickupProvider {
|
||||
/// The mapping lives in [ApiConfig.pickupFromBooking] rather than here: it is
|
||||
/// the one place that knows how a v1 booking becomes a stop, and the same
|
||||
/// translation is needed by anything else that receives a booking.
|
||||
/// ── One request, however many screens ask ──
|
||||
///
|
||||
/// Home, Bookings, Activity and the post-delivery screen all call this, and
|
||||
/// each of them used to produce its own `GET /miler/bookings` — on mount, on
|
||||
/// tab switch, and on every poll. Four timers, four copies of the day, and
|
||||
/// four ideas of what had been accepted, reconciled only by whichever
|
||||
/// happened to refresh last.
|
||||
///
|
||||
/// They all go through [WorkRepository] now, which collapses concurrent
|
||||
/// callers into one request, keeps one copy, and drops a response that lands
|
||||
/// out of order. Nothing at the call sites changed: this still returns a list
|
||||
/// or throws, which is the contract the screens were written against.
|
||||
///
|
||||
/// The richer answer — loading, empty, offline, unavailable — is on the
|
||||
/// repository for screens that want to render it properly rather than
|
||||
/// flattening it into an exception. See [LoadState].
|
||||
Future<List<dynamic>> _bookings({String? status}) async {
|
||||
final res = await MilerApi.bookings(status: status);
|
||||
if (!res.ok) {
|
||||
throw Exception('Failed (${res.status})${res.message.isEmpty ? '' : ': ${res.message}'}');
|
||||
// A filtered read is a different question and is not the one the shared
|
||||
// copy answers. Nothing calls this with a status today; if something does,
|
||||
// it gets its own request rather than silently receiving the whole day.
|
||||
if (status != null && status.isNotEmpty) {
|
||||
final res = await MilerApi.bookings(status: status);
|
||||
if (!res.ok) {
|
||||
throw Exception(
|
||||
'Failed (${res.status})'
|
||||
'${res.message.isEmpty ? '' : ': ${res.message}'}',
|
||||
);
|
||||
}
|
||||
return ApiConfig.pickupsFromBookings(res.list);
|
||||
}
|
||||
|
||||
final mapped = ApiConfig.pickupsFromBookings(res.list);
|
||||
debugPrint('[BOOKINGS] ${res.list.length} rows → ${mapped.length} stops');
|
||||
if (mapped.isEmpty && res.list.isNotEmpty) {
|
||||
// The rows arrived and none of them survived translation, which is a
|
||||
// field-name mismatch rather than an empty day. Worth saying out loud —
|
||||
// it is the difference between "no work" and "the adapter is wrong".
|
||||
ApiConfig.logGap(
|
||||
'pickupFromBooking',
|
||||
'${res.list.length} bookings returned but none mapped — check the '
|
||||
'field names against a real payload.',
|
||||
);
|
||||
}
|
||||
return mapped;
|
||||
final state = await WorkRepository.instance.load();
|
||||
return switch (state) {
|
||||
LoadData<List<Map<String, dynamic>>>(:final value) => value,
|
||||
// A real empty day. The screens render their own empty state from this.
|
||||
LoadEmpty<List<Map<String, dynamic>>>() => const <dynamic>[],
|
||||
// No endpoint for this line of work — neither waiting nor retrying helps,
|
||||
// so it must not arrive as an empty day. See [LineNotServedException].
|
||||
LoadUnavailable<List<Map<String, dynamic>>>() =>
|
||||
throw const LineNotServedException(),
|
||||
LoadFailure<List<Map<String, dynamic>>>(:final kind, :final message) =>
|
||||
throw Exception(
|
||||
message.isEmpty ? 'Could not load your work ($kind)' : message,
|
||||
),
|
||||
// Only reachable if the repository handed back its pre-load state, which
|
||||
// it does not. Treated as a failure rather than as an empty day.
|
||||
LoadLoading<List<Map<String, dynamic>>>() => throw Exception(
|
||||
'Still loading',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Everything the hub has put in front of this rider today.
|
||||
@@ -57,3 +88,18 @@ class PickupProvider {
|
||||
/// The stops he has accepted.
|
||||
Future<List<dynamic>> getPickupQueuesPicked() => _bookings();
|
||||
}
|
||||
|
||||
/// The rider's line of work has no backend to ask.
|
||||
///
|
||||
/// Distinct from an empty day and from a failed request, because the rider's
|
||||
/// answer is different in each case: wait, retry, or "your hub has not switched
|
||||
/// this on yet". Screens catch this to render the unavailable state rather than
|
||||
/// the empty one.
|
||||
class LineNotServedException implements Exception {
|
||||
const LineNotServedException();
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'This line of work is not served by the backend yet — there is no '
|
||||
'bookings endpoint for it. See ServiceProfile.hasBookingsEndpoint.';
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/assignment_lookup.dart';
|
||||
import 'package:miler/data/lifecycle.dart';
|
||||
import 'package:miler/data/consignment_state.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE PER-STOP WRITE PATH
|
||||
@@ -52,11 +55,14 @@ class CreatePickupLogProvider {
|
||||
Future<Map<String, dynamic>?> createPickupLog(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final consignmentId = data['consignmentid'] ?? data['orderheaderid'] ??
|
||||
data['pickupid'];
|
||||
final lat = double.tryParse('${data['latitude'] ?? data['riderslat'] ?? ''}');
|
||||
final lon =
|
||||
double.tryParse('${data['longitude'] ?? data['riderslon'] ?? ''}');
|
||||
final consignmentId =
|
||||
data['consignmentid'] ?? data['orderheaderid'] ?? data['pickupid'];
|
||||
final lat = double.tryParse(
|
||||
'${data['latitude'] ?? data['riderslat'] ?? ''}',
|
||||
);
|
||||
final lon = double.tryParse(
|
||||
'${data['longitude'] ?? data['riderslon'] ?? ''}',
|
||||
);
|
||||
|
||||
if (consignmentId == null || lat == null || lon == null) {
|
||||
// Not enough to place the breadcrumb. Silently skipping is right — this
|
||||
@@ -104,7 +110,16 @@ class UpdatePickupProvider {
|
||||
|
||||
Map<String, dynamic> envelope(ApiResult r) => r.ok
|
||||
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
|
||||
: {'status': false, 'code': r.status, 'message': r.message};
|
||||
: {
|
||||
'status': false,
|
||||
'code': r.status,
|
||||
'message': r.message,
|
||||
// The backend's stable failure name, alongside the HTTP status
|
||||
// this envelope has always called `code`. Callers that need to
|
||||
// branch on *why* a write was refused read this one — message text
|
||||
// is prose, and prose gets reworded.
|
||||
'errorcode': r.code,
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
@@ -133,21 +148,150 @@ class UpdatePickupProvider {
|
||||
return envelope(r);
|
||||
|
||||
case 'arrived':
|
||||
return envelope(await MilerApi.reached(id, lat: lat, lon: lon));
|
||||
// ── A 200 is not proof the hub recorded an arrival ──
|
||||
//
|
||||
// Verified against production 21 Aug 2026: `reached` returns
|
||||
// `{"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}`
|
||||
// and the booking's status is unchanged afterwards. The endpoint is a
|
||||
// no-op that reports success, so `Arrived_At_Pickup` never reaches the
|
||||
// console — and the app, which read only `ok`, drew ARRIVED anyway.
|
||||
//
|
||||
// The transition is now read for what it proves. The envelope carries
|
||||
// the verdict so the caller can advance the rider without the app
|
||||
// claiming an agreement that does not exist. See [MilerLifecycle].
|
||||
final reached = await MilerApi.reached(id, lat: lat, lon: lon);
|
||||
final arrival = MilerLifecycle.reached(reached);
|
||||
MilerLifecycle.report('reached', arrival);
|
||||
if (arrival.isUnconfirmed) {
|
||||
ApiConfig.logGap(
|
||||
'reached',
|
||||
'booking $id: the call succeeded and the booking status did not '
|
||||
'become Arrived_At_Pickup — ${arrival.evidence}. The hub '
|
||||
'cannot show this rider as arrived. Backend deployment '
|
||||
'mismatch; see MILER_API_REQUIREMENTS.md request 15.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...envelope(reached),
|
||||
'confirmed': arrival.isConfirmed,
|
||||
'serverstatus': arrival.bookingStatus.name,
|
||||
'evidence': arrival.evidence,
|
||||
};
|
||||
|
||||
case 'Picked up':
|
||||
case 'picked':
|
||||
return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon));
|
||||
// ── The pivot returns the thing the delivery leg runs on ──
|
||||
//
|
||||
// `pickup-complete` is what *creates* the consignment, and `deliver`
|
||||
// and `skip` are consignment routes. The response was being wrapped and
|
||||
// discarded, and the row the rider works from on Deliveries is a
|
||||
// snapshot taken before this call — so nothing downstream knew the
|
||||
// consignment id, and pressing **Delivered** at the door was refused by
|
||||
// the app itself. Recorded here, at the only moment it is guaranteed to
|
||||
// be in hand. See [rememberConsignmentId].
|
||||
final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon);
|
||||
// ── Which lifecycle the server is running, read from the server ──
|
||||
//
|
||||
// `Collected_By_Miler` + `next_action: start_delivery` is the new
|
||||
// two-step flow; a bare `Out_for_Delivery` is compatibility mode, in
|
||||
// which the pivot releases the consignment itself. Both are real, both
|
||||
// ship today depending on a server-side flag, and the app must never
|
||||
// mirror that flag — it reads which one happened.
|
||||
final pivot = MilerLifecycle.pickupComplete(picked);
|
||||
MilerLifecycle.report('pickup-complete', pivot);
|
||||
if (pivot.isCompatibilityMode) {
|
||||
ApiConfig.logGap(
|
||||
'pickup-complete',
|
||||
'booking $id went straight to Out_for_Delivery — the backend is '
|
||||
'running compatibility mode (MILER_COLLECTED_STATE_ENABLED '
|
||||
'off). The consignment IS out for delivery, so the console '
|
||||
'showing Active is correct and the app must agree with it.',
|
||||
);
|
||||
}
|
||||
if (picked.ok) {
|
||||
final newId = _consignmentIdFrom(picked);
|
||||
if (newId.isNotEmpty) {
|
||||
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
|
||||
await rememberConsignmentId(orderKey, newId);
|
||||
// ── What the pivot now says about what happens next ──
|
||||
//
|
||||
// Since 21 Aug 2026 the response also carries `status` and
|
||||
// `next_action`: a hyperlocal booking lands on
|
||||
// `Collected_By_Miler` with `start_delivery`, and a hub-routed one
|
||||
// on `Created` with `inward_at_hub`. Logged rather than acted on —
|
||||
// the release is the rider's press, and the states it produces are
|
||||
// read back from the consignment itself, never remembered from
|
||||
// here. See `MyPickups.startRound`.
|
||||
debugPrint(
|
||||
'[PICKUP] booking $orderKey → consignment $newId '
|
||||
'(${_field(picked, 'status')}, next: '
|
||||
'${_field(picked, 'next_action')})',
|
||||
);
|
||||
} else {
|
||||
ApiConfig.logGap(
|
||||
'pickup-complete',
|
||||
'no consignment id in the response for booking $id — the '
|
||||
'delivery leg will have to wait for the queue to carry it.',
|
||||
);
|
||||
// The one thing that makes this diagnosable next time. Debug-only:
|
||||
// a response body is not something to write into a release log.
|
||||
if (kDebugMode) {
|
||||
debugPrint('[PICKUP] pickup-complete body: ${picked.raw}');
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...envelope(picked),
|
||||
'confirmed': pivot.isConfirmed,
|
||||
// What the stop actually became, in the consignment's own words.
|
||||
// The caller stamps this rather than assuming 'picked' — the whole
|
||||
// point of the exercise is that the rider's rung and the office's
|
||||
// rung are the same rung.
|
||||
'consignmentstatus':
|
||||
pivot.consignmentState == ConsignmentState.unknown
|
||||
? ''
|
||||
: pivot.consignmentState.name,
|
||||
'nextaction': pivot.nextAction,
|
||||
'compatibilitymode': pivot.isCompatibilityMode,
|
||||
'evidence': pivot.evidence,
|
||||
};
|
||||
|
||||
case 'delivered':
|
||||
case 'Delivered':
|
||||
// ── This route keys on the CONSIGNMENT, not the booking ──
|
||||
//
|
||||
// Same trap as accept/reject and `bookingassignmentid`: two ids from
|
||||
// two sequences, and passing the wrong one gets a 404 while the rider
|
||||
// is shown success. This branch was passing `id`, which is the booking
|
||||
// — so every delivery it sent was addressed to whatever consignment
|
||||
// happened to share that number.
|
||||
//
|
||||
// The consignment exists only after `pickup-complete` has converted the
|
||||
// booking; `MilerGetMyBookings` returns it and the stop adapter carries
|
||||
// it through. Without one there is nothing to deliver *yet*, and saying
|
||||
// so is better than guessing an id.
|
||||
final consignmentId =
|
||||
(data['consignmentid'] ?? data['consignmentId'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
if (consignmentId.isEmpty) {
|
||||
ApiConfig.logGap(
|
||||
'update:delivered',
|
||||
'Booking $id has no consignmentid — it has not been collected yet, '
|
||||
'so there is nothing to deliver.',
|
||||
);
|
||||
return {
|
||||
'status': false,
|
||||
'message': 'This order has not been picked up yet.',
|
||||
};
|
||||
}
|
||||
return envelope(
|
||||
await MilerApi.deliver(
|
||||
id,
|
||||
consignmentId,
|
||||
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
|
||||
// Only sent when there is one. The tenant flag decides whether it
|
||||
// is required, and an empty string would fail a tenant that has it
|
||||
// on while being pointless for one that does not.
|
||||
// Optional: nothing generates a delivery OTP, so the handler
|
||||
// records whether one was presented rather than checking it. A
|
||||
// milk-run drop has none and must not invent one.
|
||||
otp: (data['otp'] ?? '').toString(),
|
||||
photoUrl: (data['dropimage'] ?? '').toString(),
|
||||
receiverSignatureUrl: (data['signature'] ?? '').toString(),
|
||||
@@ -185,11 +329,107 @@ class UpdatePickupProvider {
|
||||
return envelope(await MilerApi.setAvailability('On_Pickup'));
|
||||
|
||||
default:
|
||||
// ── An unmapped status is a failure, not a success ──
|
||||
//
|
||||
// This returned `okEnvelope()`: nothing left the phone, and the caller
|
||||
// was told the write had landed. The rider watched the stop advance,
|
||||
// the local stores recorded it, and the hub was never told — which is
|
||||
// the single most expensive shape of bug this app can have, because
|
||||
// every screen downstream then trusts a transition that does not exist
|
||||
// server-side.
|
||||
//
|
||||
// It also defeats the typed-status handling upstream: a status this
|
||||
// build does not recognise must never resolve to a settled state, and
|
||||
// silently succeeding here is exactly that, one layer lower.
|
||||
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
|
||||
return ApiConfig.okEnvelope();
|
||||
return {
|
||||
'status': false,
|
||||
'message': 'This app cannot record "$status" yet — nothing was sent.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The consignment id out of a `pickup-complete` response, whatever shape it
|
||||
/// arrives in.
|
||||
///
|
||||
/// Tolerant on purpose. The contract now names the field — `consignment_id`,
|
||||
/// alongside `status` and `next_action` — but this ran for a long time
|
||||
/// against a route whose response body was written down nowhere, riders are
|
||||
/// on builds that met both, and the cost of a miss is somebody at a door
|
||||
/// being told there is nothing to deliver. The search stays.
|
||||
/// One shallow field off a mutation response, for logging.
|
||||
///
|
||||
/// Deliberately not recursive and deliberately not stored: these values are
|
||||
/// a description of a moment that has already passed by the time anything
|
||||
/// would read them back.
|
||||
String _field(ApiResult res, String key) {
|
||||
final v = res.data[key];
|
||||
if (v == null || v is Map || v is List) return '?';
|
||||
final str = v.toString().trim();
|
||||
return str.isEmpty ? '?' : str;
|
||||
}
|
||||
|
||||
String _consignmentIdFrom(ApiResult res) {
|
||||
// Searched rather than looked up, at every depth. The response body for
|
||||
// this route is written down nowhere — not in the API reference, not in the
|
||||
// flow doc — so a fixed list of key names is a guess, and the cost of
|
||||
// guessing wrong is a rider standing at a door being told there is nothing
|
||||
// to deliver. Anything whose key names a consignment and whose value is a
|
||||
// plain scalar counts.
|
||||
String found = '';
|
||||
|
||||
void walk(dynamic node, int depth) {
|
||||
if (found.isNotEmpty || depth > 4 || node == null) return;
|
||||
if (node is List) {
|
||||
for (final item in node) {
|
||||
walk(item, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node is! Map) return;
|
||||
|
||||
// Exact names first, so a nested `id` never wins over a real one.
|
||||
for (final key in const [
|
||||
'consignmentid',
|
||||
'consignmentId',
|
||||
'consignment_id',
|
||||
'consignmentno',
|
||||
]) {
|
||||
final v = node[key];
|
||||
if (v != null && v is! Map && v is! List) {
|
||||
final str = v.toString().trim();
|
||||
if (str.isNotEmpty && str != '0' && str != 'null') {
|
||||
found = str;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then a `consignment` object, whose own id is the one wanted.
|
||||
final nested = node['consignment'];
|
||||
if (nested is Map) {
|
||||
for (final key in const ['consignmentid', 'consignmentId', 'id']) {
|
||||
final v = nested[key];
|
||||
if (v != null && v is! Map && v is! List) {
|
||||
final str = v.toString().trim();
|
||||
if (str.isNotEmpty && str != '0') {
|
||||
found = str;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final v in node.values) {
|
||||
walk(v, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
walk(res.data, 0);
|
||||
if (found.isEmpty) walk(res.raw, 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
/// **Step 2** — what the rider actually took, measured at the door.
|
||||
///
|
||||
/// Reads the verification screen's payload directly rather than taking a
|
||||
@@ -224,9 +464,7 @@ class UpdatePickupProvider {
|
||||
// and it keeps the chargeable total correct even though the per-parcel
|
||||
// figures are an even split rather than a measurement.
|
||||
final double each = totalWeight / count;
|
||||
final parcels = [
|
||||
for (var i = 0; i < count; i++) ParcelEntry(weight: each),
|
||||
];
|
||||
final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)];
|
||||
|
||||
final res = await MilerApi.submitParcels(bookingId, parcels);
|
||||
debugPrint(
|
||||
@@ -238,6 +476,85 @@ class UpdatePickupProvider {
|
||||
: {'status': false, 'code': res.status, 'message': res.message};
|
||||
}
|
||||
|
||||
/// **Step 1b** — the FROM and TO the rider confirmed with the customer.
|
||||
///
|
||||
/// Reads the shipment-capture screen's payload, same as [submitParcels] reads
|
||||
/// the verification screen's, so there is one record of the door rather than
|
||||
/// two argument lists to keep in step.
|
||||
///
|
||||
/// Sent before the parcel and payment steps because `pickup-complete` builds
|
||||
/// the consignment — its routing and its pricing zone — from these values,
|
||||
/// and the handler refuses them once that conversion has happened.
|
||||
Future<Map<String, dynamic>?> submitAddresses(
|
||||
Object bookingId,
|
||||
Map<String, dynamic> capture,
|
||||
) async {
|
||||
final Map? from = capture['from'] is Map ? capture['from'] as Map : null;
|
||||
final Map? to = capture['to'] is Map ? capture['to'] as Map : null;
|
||||
if (from == null && to == null) {
|
||||
return ApiConfig.okEnvelope('no addresses captured');
|
||||
}
|
||||
|
||||
String str(Map? m, String k) => (m?[k] ?? '').toString().trim();
|
||||
double? coord(Map? m, String k) => double.tryParse('${m?[k] ?? ''}');
|
||||
|
||||
final res = await MilerApi.updateBookingAddresses(
|
||||
bookingId,
|
||||
pickupAddress: str(from, 'address'),
|
||||
pickupPincode: str(from, 'pincode'),
|
||||
pickupLat: coord(from, 'lat'),
|
||||
pickupLon: coord(from, 'lon'),
|
||||
deliveryAddress: str(to, 'address'),
|
||||
deliveryPincode: str(to, 'pincode'),
|
||||
deliveryLat: coord(to, 'lat'),
|
||||
deliveryLon: coord(to, 'lon'),
|
||||
deliveryCity: str(to, 'city'),
|
||||
);
|
||||
debugPrint(
|
||||
'[ADDRESS] booking $bookingId '
|
||||
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||||
);
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status, 'message': res.message};
|
||||
}
|
||||
|
||||
/// **Step 2b** — what this shipment costs, from the pricing table.
|
||||
///
|
||||
/// Returns null when the call itself failed, and a [ShipmentQuote] with
|
||||
/// `found: false` when the table simply has no rule for this combination.
|
||||
/// The two are different things to tell a rider — "try again" versus "call
|
||||
/// the hub, this cannot be priced here" — so they stay distinguishable, and
|
||||
/// neither one produces a number.
|
||||
Future<ShipmentQuote?> fetchQuote({
|
||||
required double weight,
|
||||
required String serviceType,
|
||||
String? pickupPincode,
|
||||
String? deliveryPincode,
|
||||
String? category,
|
||||
}) async {
|
||||
final res = await MilerApi.checkPrice(
|
||||
weight: weight,
|
||||
serviceType: serviceType,
|
||||
pickupPincode: pickupPincode,
|
||||
deliveryPincode: deliveryPincode,
|
||||
category: category,
|
||||
);
|
||||
if (!res.ok) {
|
||||
debugPrint('[PRICE] FAILED ${res.status} ${res.message}');
|
||||
return null;
|
||||
}
|
||||
final quote = ShipmentQuote.fromData(
|
||||
res.map,
|
||||
preferredCategory: category ?? '',
|
||||
);
|
||||
debugPrint(
|
||||
'[PRICE] ${quote.zone}/${quote.serviceType} ${quote.weight}kg → '
|
||||
'${quote.found ? '${quote.currency} ${quote.payable}' : quote.message}',
|
||||
);
|
||||
return quote;
|
||||
}
|
||||
|
||||
/// **Step 3** — the money, from the collect-payment screen's payload.
|
||||
///
|
||||
/// Returns a success envelope without calling anything when nothing was
|
||||
|
||||
@@ -8,8 +8,7 @@ import 'package:flutter/foundation.dart';
|
||||
class SummaryProvider {
|
||||
final String baseUrl = ApiConstants.summaryApiLive;
|
||||
|
||||
Future<PickupStats?> fetchSummaryStats(int userId) =>
|
||||
_fetchSummaryStatsNew();
|
||||
Future<PickupStats?> fetchSummaryStats(int userId) => _fetchSummaryStatsNew();
|
||||
|
||||
/// NEW API: GET /miler/earnings?period=daily|weekly|monthly
|
||||
/// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }.
|
||||
|
||||
Reference in New Issue
Block a user