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:
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/providers/auth/auth_provider.dart';
|
||||
@@ -10,6 +10,7 @@ import 'package:miler/utils/device.dart';
|
||||
import 'package:miler/controllers/profile_controller.dart';
|
||||
import 'package:miler/Models/login/login.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||
|
||||
enum AuthNext { verifyPin, otp, notRegistered, error }
|
||||
|
||||
@@ -20,6 +21,20 @@ class AuthController extends GetxController {
|
||||
AuthNext? lastDecision;
|
||||
// Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry"
|
||||
VoidCallback? onPinRetry;
|
||||
|
||||
/// Why the last [verifyPinWithServer] failed, in the rider's words.
|
||||
///
|
||||
/// ── "Incorrect MPIN" was the answer to every question ──
|
||||
///
|
||||
/// The MPIN screen painted that one line whenever the controller reported a
|
||||
/// failure — a wrong PIN, a dead network, a 500, and (for a long time) a
|
||||
/// device-id lookup that threw before the request was sent. So the one
|
||||
/// symptom a rider could report was the one cause that was often not true,
|
||||
/// and there was no way to tell a mistyped PIN from an app that was never
|
||||
/// going to reach the server.
|
||||
///
|
||||
/// Set on every failure path, cleared on success. Read by `Mpin.dart`.
|
||||
String? lastPinFailure;
|
||||
static const String _prefsUserIdKey = 'userid';
|
||||
static const String _prefsPendingPinUserIdKey = 'pending_pin_userid';
|
||||
static const String _prefsUserNameKey = 'user_name';
|
||||
@@ -56,54 +71,31 @@ class AuthController extends GetxController {
|
||||
}
|
||||
|
||||
void _showBottomSheet({required String title, required String message}) {
|
||||
// `Get.bottomSheet` stays (this controller has no BuildContext for the
|
||||
// kit's presenter), but the surface inside it is the kit's — the same
|
||||
// glass, handle and insets as every sheet after sign-in, so the first
|
||||
// sheet a rider ever meets is not the one drawn differently.
|
||||
Get.bottomSheet(
|
||||
Builder(
|
||||
builder: (context) => Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16 + MediaQuery.of(context).viewPadding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: ColorConstants.primary, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MilerButton(
|
||||
label: 'Retry',
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
// If MPIN screen has registered a retry callback, run it
|
||||
onPinRetry?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
MilerSheetScaffold(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
MilerSheetHeader(
|
||||
title: title,
|
||||
subtitle: message,
|
||||
icon: LucideIcons.info,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
MilerButton(
|
||||
label: 'Retry',
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
// If MPIN screen has registered a retry callback, run it
|
||||
onPinRetry?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
@@ -130,13 +122,19 @@ class AuthController extends GetxController {
|
||||
// Create-MPIN, which would overwrite the PIN the account was issued.
|
||||
// Seeded development accounts take exactly this branch — enter the phone,
|
||||
// enter the seeded MPIN, done.
|
||||
// Null means the directory could not be reached — see
|
||||
// [AuthProvider.milerAccountExists]. Treat it as "he has an account",
|
||||
// because that is true of every rider who gets this far and because the
|
||||
// MPIN screen is the only one that can tell him what went wrong. The OTP
|
||||
// branch is the dead end: it ends at Create-MPIN, which cannot write a
|
||||
// PIN, so guessing wrong in that direction locks a rider out.
|
||||
final exists = await _api.milerAccountExists(normalized);
|
||||
if (exists) {
|
||||
if (exists ?? true) {
|
||||
lastDecision = AuthNext.verifyPin;
|
||||
return lastDecision!;
|
||||
}
|
||||
|
||||
// Unknown number (or legacy backend) — fall through to the OTP step.
|
||||
// The directory answered, and said there is no such account.
|
||||
lastDecision = AuthNext.otp;
|
||||
return lastDecision!;
|
||||
} catch (e) {
|
||||
@@ -160,8 +158,24 @@ class AuthController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// ── There is no OTP route on the backend ──
|
||||
///
|
||||
/// This returned true with the comment "automatically succeed for mocked
|
||||
/// login", which read as leftover demo scaffolding. It is not: `MilerApi`
|
||||
/// carries the whole auth surface and it is three routes — `login`,
|
||||
/// `verify-pin`, `device-token`. Nothing verifies a code, so there is nothing
|
||||
/// for this to call.
|
||||
///
|
||||
/// It stays a pass-through for the same reason [AuthProvider.updatePin] does:
|
||||
/// failing instead would strand a new rider on a screen with no way forward,
|
||||
/// which is worse and no more honest. What changes is that the gap is now
|
||||
/// recorded rather than described as a mock, so it shows up in the same place
|
||||
/// as every other missing route.
|
||||
Future<bool> verifyOtp(String code) async {
|
||||
// Automatically succeed for mocked login
|
||||
ApiConfig.logGap(
|
||||
'verifyOtp',
|
||||
'No OTP verification route exists; the code entered is not checked.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -185,15 +199,6 @@ class AuthController extends GetxController {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Demo mode: the mocked login flow stores a fake rider id (9999) that
|
||||
// the live server rejects, so updatePin fails. Save the PIN locally and
|
||||
// report success so the demo Create-MPIN flow works without a real
|
||||
// account or server call.
|
||||
if (userId == 9999) {
|
||||
await prefs.setString('dbPin', newPin);
|
||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
final int pinNum = int.parse(newPin);
|
||||
final res = await _api.updatePin(userId: userId, pin: pinNum);
|
||||
@@ -202,13 +207,17 @@ class AuthController extends GetxController {
|
||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||
return true;
|
||||
}
|
||||
final bodyPreview = res.body.length > 200
|
||||
? '${res.body.substring(0, 200)}...'
|
||||
: res.body;
|
||||
_showBottomSheet(
|
||||
title: 'Failed (${res.statusCode})',
|
||||
message: 'Unable to set PIN. Server said: $bodyPreview',
|
||||
);
|
||||
// The server's own sentence, not a slice of its JSON. A rider reading
|
||||
// `{"status":false,"code":403,...}` learns nothing he can act on.
|
||||
String reason = '';
|
||||
try {
|
||||
final decoded = json.decode(res.body);
|
||||
if (decoded is Map) reason = (decoded['message'] ?? '').toString();
|
||||
} catch (_) {}
|
||||
if (reason.trim().isEmpty) {
|
||||
reason = 'Could not set your MPIN. Please contact your hub manager.';
|
||||
}
|
||||
_showBottomSheet(title: 'MPIN not changed', message: reason);
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('setPin error: $e');
|
||||
@@ -281,15 +290,52 @@ class AuthController extends GetxController {
|
||||
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
|
||||
final int? pinNum = int.tryParse(inputPin);
|
||||
if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
|
||||
_showBottomSheet(
|
||||
title: 'Invalid PIN',
|
||||
message: 'Please enter your 4-digit PIN and try again.',
|
||||
);
|
||||
// Two different faults wearing one message. A missing phone is not the
|
||||
// rider mistyping — it means he reached this screen without the number
|
||||
// step, and telling him to re-enter his PIN sends him round a loop that
|
||||
// cannot end.
|
||||
lastPinFailure = phone.isEmpty
|
||||
? 'We lost your phone number. Go back and enter it again.'
|
||||
: 'Enter all 4 digits of your MPIN.';
|
||||
_showBottomSheet(title: 'Invalid PIN', message: lastPinFailure!);
|
||||
return false;
|
||||
}
|
||||
|
||||
final deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
||||
final fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
||||
// ── Nothing gathered here may stop the sign-in ──
|
||||
//
|
||||
// These two lines used to sit bare inside this `try`, and
|
||||
// `ensureDeviceId` threw on iOS and on any Android that handed back an
|
||||
// empty id. The throw landed in the catch below, so the rider was told
|
||||
// "Could not reach the server" — before a single byte had been sent —
|
||||
// and the MPIN screen then called it an incorrect PIN. Every number,
|
||||
// every attempt.
|
||||
//
|
||||
// `ensureDeviceId` is total now (see [DeviceUtils]), and this second
|
||||
// guard says why it must stay that way: `deviceId` is not even part of
|
||||
// the verify-pin body, and a push token the rider declined is not a
|
||||
// reason to refuse him his shift. Best effort, then post regardless.
|
||||
String deviceId = '';
|
||||
String fcmToken = '';
|
||||
try {
|
||||
deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
||||
} catch (e) {
|
||||
debugPrint('[AUTH] device id unavailable, continuing: $e');
|
||||
}
|
||||
try {
|
||||
fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
||||
} catch (e) {
|
||||
debugPrint('[AUTH] fcm token unavailable, continuing: $e');
|
||||
}
|
||||
|
||||
// ── This attempt is the only thing that may grant a session ──
|
||||
//
|
||||
// The check below asks prefs whether a token exists. Without this line
|
||||
// that question is answered by *any previous session*, so the gate was
|
||||
// broken in both directions: a stale token made a rejected PIN look
|
||||
// accepted, and a fresh install with a token the app could not find made
|
||||
// an accepted PIN look rejected.
|
||||
await ApiConfig.clearToken();
|
||||
|
||||
final Login res = await _api.loginParsed(
|
||||
contactNo: phone,
|
||||
deviceType: Platform.operatingSystem,
|
||||
@@ -300,9 +346,38 @@ class AuthController extends GetxController {
|
||||
pinRaw: inputPin,
|
||||
);
|
||||
|
||||
// ── Three outcomes, not two ──
|
||||
//
|
||||
// `res.status` is the server's verdict on the credentials. The token is
|
||||
// whether this call handed back a session. They are different facts, and
|
||||
// collapsing them into one boolean is what produced **"Login failed"** on
|
||||
// a PIN the server had just accepted — the app could not find the token
|
||||
// in the response, so it reported the rider's PIN as wrong.
|
||||
final bool serverAccepted = res.status == true;
|
||||
final String? token = await ApiConfig.getToken();
|
||||
final bool ok = res.status == true && token != null && token.isNotEmpty;
|
||||
final bool haveSession = token != null && token.isNotEmpty;
|
||||
final bool ok = serverAccepted && haveSession;
|
||||
|
||||
if (serverAccepted && !haveSession) {
|
||||
// The credentials were right and there is nothing to sign in with.
|
||||
// This is an integration fault, not a rider fault, and it must never
|
||||
// again be reported as a bad PIN. The log line above it names the keys
|
||||
// the response actually carried.
|
||||
debugPrint(
|
||||
'[AUTH] verify-pin accepted the PIN but returned no usable token',
|
||||
);
|
||||
lastPinFailure =
|
||||
'Your MPIN was accepted, but the server did not return a session. '
|
||||
'Please report this to the hub — it is not your PIN.';
|
||||
_showBottomSheet(
|
||||
title: 'Could not start session',
|
||||
message: lastPinFailure!,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
lastPinFailure = null;
|
||||
await prefs.setString('dbPin', inputPin);
|
||||
await prefs.setBool('logged_out', false);
|
||||
currentPhone = _normalizePhone(phone);
|
||||
@@ -329,19 +404,40 @@ class AuthController extends GetxController {
|
||||
return true;
|
||||
}
|
||||
|
||||
_showBottomSheet(
|
||||
title: 'Login failed',
|
||||
message: (res.message != null && res.message!.trim().isNotEmpty)
|
||||
? res.message!
|
||||
: 'Incorrect phone number or PIN. Please try again.',
|
||||
);
|
||||
// ── Only 401/403 is a statement about the PIN ──
|
||||
//
|
||||
// Everything else — a 502, a gateway timeout, a captive portal, a body
|
||||
// that is not JSON — is the sign-in failing to *complete*, which is a
|
||||
// different problem with a different fix. Reporting all of it as a login
|
||||
// failure is what made a network fault indistinguishable from a wrong
|
||||
// MPIN, on a screen whose whole job is to tell those apart.
|
||||
final int status = res.code ?? 0;
|
||||
final String serverSaid = (res.message ?? '').trim();
|
||||
final bool aboutTheCredentials = status == 401 || status == 403;
|
||||
|
||||
if (aboutTheCredentials) {
|
||||
lastPinFailure = serverSaid.isNotEmpty
|
||||
? serverSaid
|
||||
: 'Incorrect MPIN for $phone. Try again.';
|
||||
_showBottomSheet(title: 'Login failed', message: lastPinFailure!);
|
||||
} else {
|
||||
lastPinFailure = serverSaid.isNotEmpty
|
||||
? 'Sign-in could not complete. $serverSaid'
|
||||
: 'Sign-in could not complete (HTTP $status). This is not your '
|
||||
'MPIN — check the connection and try again.';
|
||||
_showBottomSheet(
|
||||
title: 'Sign-in did not complete',
|
||||
message: lastPinFailure!,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
// Never reached the server, or could not read what came back. Whatever
|
||||
// this is, it is NOT the rider's PIN, and saying so is the whole point.
|
||||
debugPrint('verifyPinWithServer error: $e');
|
||||
_showBottomSheet(
|
||||
title: 'Connection error',
|
||||
message: 'Could not reach the server. Check your internet and retry.',
|
||||
);
|
||||
lastPinFailure =
|
||||
'Could not reach the server. Check your connection and try again.';
|
||||
_showBottomSheet(title: 'Connection error', message: lastPinFailure!);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,6 @@ class LogController extends GetxController {
|
||||
final List<String> remaining = [];
|
||||
bool anySuccess = false;
|
||||
|
||||
|
||||
for (final itemStr in queue) {
|
||||
try {
|
||||
final Map<String, dynamic> item = jsonDecode(itemStr);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:miler/data/work_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
import 'package:miler/helpers/miler_router.dart';
|
||||
@@ -17,16 +20,61 @@ import 'package:miler/utils/kalman_filter.dart';
|
||||
import 'package:miler/utils/mqtt_service.dart';
|
||||
import 'package:miler/controllers/connectivity_mixin.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/data/meal_run_mock.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
|
||||
/// Testing/demo flag. When `true`, the proximity ("you are too far from the
|
||||
/// location") geofence is skipped so mock stops far from the rider can still be
|
||||
/// completed while testing the design.
|
||||
/// ── PROXIMITY ENFORCEMENT IS OFF ──
|
||||
///
|
||||
/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds — the
|
||||
/// geofence can never be accidentally shipped disabled, but debug/testing keeps
|
||||
/// working with mock stops.
|
||||
const bool kBypassGeofenceForTesting = kDebugMode;
|
||||
/// Turned off deliberately on 2026-08-19, at the founder's call, because it was
|
||||
/// refusing real work: riders pressing **Picked up** at a counter were told
|
||||
/// "You're 4.2 km from this stop", and a rider who cannot record what he has
|
||||
/// physically done has no way round it. Correctness of the fence matters less
|
||||
/// than a rider being able to work.
|
||||
///
|
||||
/// It is off for **every** status — accepted, arrived, picked up, out for
|
||||
/// delivery, delivered — and on both gates, this one and the bulk check on
|
||||
/// Home. Half a fence is worse than none: it would block one route into a rung
|
||||
/// and wave through another, with two different messages and no explanation of
|
||||
/// why one worked.
|
||||
///
|
||||
/// ── Turning it back on ──
|
||||
///
|
||||
/// flutter run --dart-define=ENFORCE_GEOFENCE=true
|
||||
/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true
|
||||
///
|
||||
/// Or flip [kGeofenceEnforced]'s default back to `true` when the underlying
|
||||
/// problem is fixed. That problem is worth naming, because it is the reason
|
||||
/// this is off rather than merely loosened: the fence compares the rider's GPS
|
||||
/// against `pickuplat`/`pickuplon` **on the booking**, and those come from
|
||||
/// wherever the customer dropped a pin — so the distance it measures is as
|
||||
/// often the data being wrong as the rider being absent.
|
||||
///
|
||||
/// ── What it costs while it is off ──
|
||||
///
|
||||
/// This is the one control that decided whether the app was telling the truth
|
||||
/// about where a rider was standing when he said a stop was done. With it off,
|
||||
/// "Picked up" means he pressed a button, not that he was there — the
|
||||
/// timestamps and the GPS still ride along on every status write, so the office
|
||||
/// can audit after the fact, but nothing is refused at the moment of the press.
|
||||
///
|
||||
/// Every bypass is logged in every build mode (see `_checkGeofence`), so a
|
||||
/// build's own log says which way it was compiled.
|
||||
///
|
||||
/// ── The history, so neither old mistake comes back ──
|
||||
///
|
||||
/// This was once `kDebugMode`, which meant the fence was off in exactly the
|
||||
/// builds anyone tested with — so it was never really tested. It was then
|
||||
/// pinned to a hard `false`, which left no way to walk the flow at a desk. The
|
||||
/// shape below survives both: one named constant, one define, one default, and
|
||||
/// the default is a product decision rather than a side effect of how the app
|
||||
/// was built.
|
||||
const bool kGeofenceEnforced = bool.fromEnvironment(
|
||||
'ENFORCE_GEOFENCE',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
/// The name every call site reads. Derived, so there is one switch and not two.
|
||||
const bool kBypassGeofenceForTesting = !kGeofenceEnforced;
|
||||
|
||||
class PickupsController extends GetxController
|
||||
with ConnectivityControllerMixin {
|
||||
@@ -66,6 +114,17 @@ class PickupsController extends GetxController
|
||||
// Bonus Points Tracking
|
||||
final RxInt lastBonusPoints = 0.obs;
|
||||
|
||||
/// The tracking number the last `pickup-complete` minted, or '' when the call
|
||||
/// has not run or did not return one.
|
||||
///
|
||||
/// `pickup-complete` is the moment a booking becomes a real consignment, and
|
||||
/// it answers with `{tracking_no, consignment_id, booking_no}` — the shipment
|
||||
/// the rider has just brought into existence. That payload was being
|
||||
/// discarded, so the success screen could only say "done" in the abstract.
|
||||
/// Showing the tracking number makes it a receipt: the rider can read it back
|
||||
/// to a customer who asks, on the spot, without phoning the hub.
|
||||
final RxString lastTrackingNo = ''.obs;
|
||||
|
||||
// ── Compliance of the stop just completed ──
|
||||
//
|
||||
// All three are computed here already — the bonus rule is literally "did he
|
||||
@@ -79,12 +138,35 @@ class PickupsController extends GetxController
|
||||
// be measured) — which the UI states as unknown rather than as a failure.
|
||||
final Rxn<bool> lastOnTime = Rxn<bool>();
|
||||
final Rxn<Duration> lastLateBy = Rxn<Duration>();
|
||||
|
||||
/// The clock the stop was promised by, as read from `eta_endtime_<orderid>`
|
||||
/// at the moment it closed.
|
||||
///
|
||||
/// The verdict — on time or late by how much — was the only thing kept, and
|
||||
/// it answers "did he make it" without ever answering "by when". A rider
|
||||
/// asked about a stop wants both, and the deadline itself lives in a prefs
|
||||
/// key the next stop overwrites. Held here so the completion path can write
|
||||
/// it onto the record, the same argument [StopCompliance] makes for the two
|
||||
/// figures beside it.
|
||||
final Rxn<DateTime> lastEtaDeadline = Rxn<DateTime>();
|
||||
final RxDouble lastRiderKms = 0.0.obs;
|
||||
|
||||
// Trigger to refresh pickup list across screens
|
||||
final RxInt refreshTrigger = 0.obs;
|
||||
|
||||
/// ── After a mutation, the server is asked, not assumed ──
|
||||
///
|
||||
/// This bumped a counter and every screen listening re-read *its own* copy of
|
||||
/// the day — so accepting a stop on Home updated Home, and Bookings and
|
||||
/// Activity carried on showing the state from before the tap until their own
|
||||
/// poll came round.
|
||||
///
|
||||
/// The shared copy is invalidated first, so the re-read that the counter
|
||||
/// triggers goes to the API rather than to a cached answer that predates the
|
||||
/// mutation. One request, and all three screens land on the state the server
|
||||
/// actually confirmed rather than on the one the button implied.
|
||||
void triggerRefresh() {
|
||||
unawaited(WorkRepository.instance.invalidate());
|
||||
refreshTrigger.value++;
|
||||
}
|
||||
|
||||
@@ -95,11 +177,42 @@ class PickupsController extends GetxController
|
||||
int userId,
|
||||
int pickupId,
|
||||
) async {
|
||||
// Nothing to store proof against on the meal line — the crate photo has no
|
||||
// booking behind it yet, so it stays on the phone rather than filling a
|
||||
// bucket with pictures of a day that only exists in memory.
|
||||
if (MealRunMock.active) {
|
||||
debugPrint('[MEAL_MOCK] crate photo kept local: ${imageFile.path}');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── These were literals in the source ──
|
||||
//
|
||||
// A DigitalOcean Spaces access key and secret key, compiled into every APK
|
||||
// the app has ever shipped. Anyone with the binary can extract them with
|
||||
// `strings`, and they are read-write on the whole `doormile` bucket — every
|
||||
// rider's proof photo, for every tenant, readable and deletable by anybody
|
||||
// who has installed the app once.
|
||||
//
|
||||
// Moving them to `--dart-define` stops the next build embedding them. It
|
||||
// does **not** undo the exposure: the pair below has been distributed and
|
||||
// is in this repository's history, so it has to be **rotated**, and the
|
||||
// upload belongs behind a server-issued signed URL rather than in the
|
||||
// client at all. Both of those are ops decisions, so this change makes the
|
||||
// dependency explicit and loud rather than pretending to fix it.
|
||||
const String accessKey = String.fromEnvironment('SPACES_KEY');
|
||||
const String secretKey = String.fromEnvironment('SPACES_SECRET');
|
||||
if (accessKey.isEmpty || secretKey.isEmpty) {
|
||||
debugPrint(
|
||||
'[UPLOAD] refused: SPACES_KEY/SPACES_SECRET are not set on this build. '
|
||||
'Pass them with --dart-define, and rotate the pair that used to be '
|
||||
'hard-coded here.',
|
||||
);
|
||||
AppFeedback.errorGlobal('Photo upload is not configured on this build.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// final rng = Random(); // Unused
|
||||
const String region = "sgp1";
|
||||
const String accessKey = "DO00NQER7N2FRYZAB2HR";
|
||||
const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0";
|
||||
const String bucketName = "doormile";
|
||||
// folderName will be "picked" or "Picked up"
|
||||
|
||||
@@ -237,10 +350,40 @@ class PickupsController extends GetxController
|
||||
}
|
||||
|
||||
// Picked
|
||||
/// ── The meal line has nothing to post to ──
|
||||
///
|
||||
/// There are no meal endpoints. Every status method below would otherwise
|
||||
/// build a payload and fire it at a parcel URL that has never heard of a
|
||||
/// crate — and, worse, run the proximity check against a mock address in
|
||||
/// Coimbatore, so a rider walking the flow anywhere else is blocked at the
|
||||
/// first door by a fence guarding fictional coordinates.
|
||||
///
|
||||
/// So on that line the status is recorded in [MealRunMock] and reported as
|
||||
/// sent. The flow is identical; only the wire is absent. Returns null when
|
||||
/// this is a real parcel call, so the caller carries on.
|
||||
///
|
||||
/// See [MealRunMock] for why this cannot reach a parcel rider.
|
||||
bool? _mockStatus(int pickupId, String status) {
|
||||
if (!MealRunMock.active) return null;
|
||||
lastBlockedReason = null;
|
||||
return MealRunMock.setStatusByPickupId(pickupId, status);
|
||||
}
|
||||
|
||||
Future<bool> updatePickedStatus({
|
||||
required int pickupId,
|
||||
required int orderHeaderId,
|
||||
required int pickupLocationId,
|
||||
|
||||
/// ── The key the delivery leg will look this stop up by ──
|
||||
///
|
||||
/// `pickup-complete` is what creates the consignment, and the id it returns
|
||||
/// is filed against this order so the door can find it later. The payload
|
||||
/// carried no `orderid`, so the provider fell back to the *booking* id —
|
||||
/// and `closeDelivery` reads the map by **order** id. Every collected stop
|
||||
/// was therefore filed under a key nothing would ever ask for, and pressing
|
||||
/// **Delivered** answered "this order was never picked up on the system"
|
||||
/// for a bag the rider was holding. See [rememberConsignmentId].
|
||||
String orderId = '',
|
||||
String ridersLat = '0',
|
||||
String ridersLng = '0',
|
||||
String pickupLat = '0',
|
||||
@@ -255,6 +398,9 @@ class PickupsController extends GetxController
|
||||
double actualKms = 0.0,
|
||||
String? proofImage, // New parameter
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'picked');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
_pickedTime = _formatDateTimeFull(now);
|
||||
@@ -307,6 +453,9 @@ class PickupsController extends GetxController
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'pickupid': pickupId,
|
||||
// Not sent to the backend — the legacy shape carries it and the mapper
|
||||
// reads it to file the consignment under the id the door will use.
|
||||
if (orderId.isNotEmpty) 'orderid': orderId,
|
||||
'orderheaderid': orderHeaderId,
|
||||
'pickuplocationid': pickupLocationId,
|
||||
'orderstatus': 'picked',
|
||||
@@ -336,6 +485,30 @@ class PickupsController extends GetxController
|
||||
if (!ok) {
|
||||
debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}');
|
||||
} else {
|
||||
// ── What the stop actually became, in the server's words ──
|
||||
//
|
||||
// The pivot has two legitimate outcomes and the caller must stamp the
|
||||
// one that happened, not the one this build was written against:
|
||||
//
|
||||
// `collectedByMiler` the rider holds it — PICKED, and Start
|
||||
// delivery is what makes it active.
|
||||
// `outForDelivery` compatibility mode — the pivot released it,
|
||||
// so it is ALREADY out for delivery and the
|
||||
// console is right to say Active.
|
||||
//
|
||||
// Stamping 'picked' in the second case is the mismatch this whole
|
||||
// exercise is about: the rider would read PICKED off a consignment his
|
||||
// office reads as Active, and neither of them would be wrong about
|
||||
// what their own screen said.
|
||||
lastPivotConsignmentStatus = (resp?['consignmentstatus'] ?? '')
|
||||
.toString();
|
||||
lastPivotCompatibilityMode = resp?['compatibilitymode'] == true;
|
||||
lastPivotNextAction = (resp?['nextaction'] ?? '').toString();
|
||||
debugPrint(
|
||||
'[UPDATE][PICKED] server state="$lastPivotConsignmentStatus" '
|
||||
'next="$lastPivotNextAction" '
|
||||
'compatibility=$lastPivotCompatibilityMode',
|
||||
);
|
||||
// --- MQTT LOGIC ---
|
||||
MilerMqttService().publishLog('pickup_picked', payload);
|
||||
}
|
||||
@@ -366,7 +539,11 @@ class PickupsController extends GetxController
|
||||
String orderId = '', // New parameter for timer/bonus logic
|
||||
String? proofImage, // New parameter
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'picked');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
lastBlockedReason = null;
|
||||
pickedShimmer.value = true;
|
||||
// ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately
|
||||
final prefsFuture = SharedPreferences.getInstance();
|
||||
@@ -620,6 +797,7 @@ class PickupsController extends GetxController
|
||||
// stale value from the previous one would be stamped onto this record.
|
||||
lastOnTime.value = null;
|
||||
lastLateBy.value = null;
|
||||
lastEtaDeadline.value = null;
|
||||
lastRiderKms.value = calculatedRiderKms;
|
||||
if (orderId.isNotEmpty) {
|
||||
try {
|
||||
@@ -629,6 +807,9 @@ class PickupsController extends GetxController
|
||||
if (endSeconds != null) {
|
||||
final currentSeconds =
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
lastEtaDeadline.value = DateTime.fromMillisecondsSinceEpoch(
|
||||
endSeconds * 1000,
|
||||
);
|
||||
lastOnTime.value = currentSeconds <= endSeconds;
|
||||
lastLateBy.value = currentSeconds <= endSeconds
|
||||
? Duration.zero
|
||||
@@ -770,6 +951,15 @@ class PickupsController extends GetxController
|
||||
final resp = await _updateProvider.updatePickup(payload);
|
||||
final ok = _isSuccess(resp);
|
||||
|
||||
// Reset first: a stale number from the previous stop shown on this one's
|
||||
// success screen would be worse than none.
|
||||
lastTrackingNo.value = '';
|
||||
if (ok && resp?['details'] is Map) {
|
||||
final details = resp!['details'] as Map;
|
||||
lastTrackingNo.value =
|
||||
(details['tracking_no'] ?? details['trackingno'] ?? '').toString();
|
||||
}
|
||||
|
||||
// Save current RIDER GPS location as last pickup location for next pickup
|
||||
if (ok) {
|
||||
final actualLat = ll['lat'] ?? '0';
|
||||
@@ -871,6 +1061,18 @@ class PickupsController extends GetxController
|
||||
}
|
||||
|
||||
// Helper method to show snackbar reliably in both debug and release builds
|
||||
//
|
||||
// ── It has to say what actually happened ──
|
||||
//
|
||||
// This took a title and a message, built neither into anything, and printed
|
||||
// the string "Something went wrong" instead — every time, for every reason.
|
||||
// The one case that matters most is the proximity block: the caller composes
|
||||
// "You are 4,200 m from the stop, you need to be within 100 m", and the rider
|
||||
// was shown three words that told him nothing and implied a bug in the app
|
||||
// rather than a thing he could fix by riding to the address.
|
||||
//
|
||||
// The message is now the message. The title is still only for the log — the
|
||||
// snackbar is one line and a heading above it would push the fact off it.
|
||||
void _showErrorSnackbar(
|
||||
String title,
|
||||
String message, {
|
||||
@@ -884,15 +1086,10 @@ class PickupsController extends GetxController
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[GEOFENCE] $title: $message');
|
||||
|
||||
try {
|
||||
final context = Get.key.currentContext;
|
||||
|
||||
// Get bottom safe area padding
|
||||
final double bottomSafePadding = context != null
|
||||
? MediaQuery.of(context).padding.bottom
|
||||
: 0;
|
||||
|
||||
AppFeedback.errorGlobal('Something went wrong');
|
||||
AppFeedback.errorGlobal(message);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e',
|
||||
@@ -903,7 +1100,7 @@ class PickupsController extends GetxController
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 400), () {
|
||||
try {
|
||||
AppFeedback.errorGlobal('Something went wrong');
|
||||
AppFeedback.errorGlobal(message);
|
||||
} catch (e2) {
|
||||
debugPrint('[GEOFENCE] Retry snackbar failed: $e2');
|
||||
|
||||
@@ -923,6 +1120,24 @@ class PickupsController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the last status update refused to run, when it refused for a reason
|
||||
/// the rider can act on — today that is only the proximity check. Null when
|
||||
/// nothing blocked it.
|
||||
///
|
||||
/// ── Refused is not failed ──
|
||||
///
|
||||
/// [updatePickedupStatus] and friends return `false` for two very different
|
||||
/// events: the server said no / the request never left the phone, and *the
|
||||
/// app itself declined to send it* because the rider is not at the address.
|
||||
/// Callers treat a `false` of the first kind optimistically — the stop is
|
||||
/// finished locally and the rider moves on, because a dead network must not
|
||||
/// strand him at a door. Doing that for the second kind is how a stop the hub
|
||||
/// was never told about ends up on the success screen.
|
||||
///
|
||||
/// Set immediately before the refusal, cleared at the top of every update, so
|
||||
/// a caller can read it straight after its `await`.
|
||||
String? lastBlockedReason;
|
||||
|
||||
Future<bool> _checkGeofence(
|
||||
double targetLat,
|
||||
double targetLng,
|
||||
@@ -930,12 +1145,21 @@ class PickupsController extends GetxController
|
||||
double currentLng,
|
||||
String action,
|
||||
) async {
|
||||
// Testing/demo bypass: skip proximity enforcement so mock stops far from
|
||||
// the rider can still be completed. Remove/flip for production.
|
||||
lastBlockedReason = null;
|
||||
|
||||
// Opt-in via `--dart-define=BYPASS_GEOFENCE=true`; enforced otherwise. See
|
||||
// the declaration for why it is a define rather than a constant.
|
||||
//
|
||||
// Logged with `debugPrint` in *every* build mode, deliberately — not behind
|
||||
// `kDebugMode` like the diagnostics below. The whole risk of this flag is a
|
||||
// build going out with proximity silently off, so the one thing it must
|
||||
// never be is quiet.
|
||||
if (kBypassGeofenceForTesting) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[GEOFENCE] Bypassed for $action (testing flag on)');
|
||||
}
|
||||
debugPrint(
|
||||
'[GEOFENCE] OFF for $action — enforcement is disabled in this build. '
|
||||
'Stop completion is NOT proximity-verified. '
|
||||
'See kGeofenceEnforced.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -988,12 +1212,18 @@ class PickupsController extends GetxController
|
||||
}
|
||||
|
||||
if (distance > radius) {
|
||||
// Show error snackbar with clear message using helper method
|
||||
_showErrorSnackbar(
|
||||
'Location Error',
|
||||
'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m',
|
||||
seconds: 5,
|
||||
);
|
||||
// ── One sentence, in metres he can act on ──
|
||||
//
|
||||
// Was three lines of "Distance: 4213 m (4.21 km) / Required: Within
|
||||
// 100 m" — a readout, in a snackbar, on a phone in a jacket pocket.
|
||||
// What the rider needs is how far he still has to go.
|
||||
final String away = distanceMeters >= 1000
|
||||
? '${distanceKm.toStringAsFixed(1)} km'
|
||||
: '${distanceMeters.toStringAsFixed(0)} m';
|
||||
lastBlockedReason =
|
||||
"You're $away from this stop — get within $radius m to mark it "
|
||||
'${action.toLowerCase()}';
|
||||
_showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1166,6 +1396,9 @@ class PickupsController extends GetxController
|
||||
String ridersLng = '0',
|
||||
String notes = '',
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'accepted');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||
final acceptedTime = _formatDateTimeFull(DateTime.now());
|
||||
@@ -1213,6 +1446,9 @@ class PickupsController extends GetxController
|
||||
String notes = '',
|
||||
String orderId = '',
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'active');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
_activeTime = _formatDateTimeFull(now);
|
||||
@@ -1342,6 +1578,29 @@ class PickupsController extends GetxController
|
||||
}
|
||||
|
||||
// Arrived
|
||||
/// The consignment state the last `pickup-complete` reported, as a
|
||||
/// [ConsignmentState] name. Empty when the response named none.
|
||||
///
|
||||
/// Read by Home to stamp the rung the **server** produced rather than the
|
||||
/// one this build expects. See [updatePickedStatus].
|
||||
String lastPivotConsignmentStatus = '';
|
||||
|
||||
/// True when that pivot released the consignment itself — the backend's
|
||||
/// compatibility mode, and the reason Admin shows Active for a fresh pickup.
|
||||
bool lastPivotCompatibilityMode = false;
|
||||
|
||||
/// The server's own instruction for what comes next, e.g. `start_delivery`.
|
||||
String lastPivotNextAction = '';
|
||||
|
||||
/// Whether the **server** confirmed the last arrival, not whether the call
|
||||
/// returned 200. See [updateArrivedStatus].
|
||||
final RxBool arrivalConfirmedByServer = true.obs;
|
||||
|
||||
/// What to tell the rider when it did not. Null when there is nothing to
|
||||
/// say, which is the state this should be in once the backend ships a
|
||||
/// `reached` that writes `Arrived_At_Pickup`.
|
||||
String? lastArrivalNotice;
|
||||
|
||||
Future<bool> updateArrivedStatus({
|
||||
required int pickupId,
|
||||
required int orderHeaderId,
|
||||
@@ -1351,6 +1610,9 @@ class PickupsController extends GetxController
|
||||
String pickupLng = '0',
|
||||
String notes = '',
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'arrived');
|
||||
if (mock != null) return mock;
|
||||
|
||||
// START LOADING IMMEDIATELY for better UX
|
||||
arrivedShimmer.value = true;
|
||||
try {
|
||||
@@ -1398,8 +1660,34 @@ class PickupsController extends GetxController
|
||||
|
||||
if (!ok) {
|
||||
debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}');
|
||||
return false;
|
||||
}
|
||||
return ok;
|
||||
|
||||
// ── Succeeded, but did the hub record it? ──
|
||||
//
|
||||
// Two different answers, and this used to return the first as if it were
|
||||
// the second. `reached` answers 200 `success: true` and leaves the
|
||||
// booking status untouched (verified in production 21 Aug 2026), so the
|
||||
// rider saw ARRIVED and the office saw nothing — for as long as that
|
||||
// endpoint stays a no-op, which is not something the app can fix.
|
||||
//
|
||||
// What the app can do is stop asserting the agreement. The rung still
|
||||
// advances, because a rider standing at a kitchen cannot be blocked by
|
||||
// somebody else's deployment; but it advances as *his* record, and the
|
||||
// discrepancy is named rather than hidden behind a tick.
|
||||
arrivalConfirmedByServer.value = resp?['confirmed'] == true;
|
||||
if (!arrivalConfirmedByServer.value) {
|
||||
lastArrivalNotice =
|
||||
'Marked arrived on your phone. Your hub has not recorded it — '
|
||||
'their system is not accepting arrivals yet.';
|
||||
debugPrint(
|
||||
'[UPDATE][ARRIVED][UNCONFIRMED] server said '
|
||||
'"${resp?['serverstatus']}" — ${resp?['evidence']}',
|
||||
);
|
||||
} else {
|
||||
lastArrivalNotice = null;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[UPDATE][ARRIVED][ERROR] $e');
|
||||
arrivedShimmer.value = false;
|
||||
@@ -1407,6 +1695,185 @@ class PickupsController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// THE MILK RUN'S DELIVERY LEG
|
||||
//
|
||||
// Deliberately separate methods rather than a flag on the pickup ones. The
|
||||
// pickup path measures rider kilometres, awards a punctuality bonus, saves a
|
||||
// "last pickup location" for the next leg's distance and recomputes
|
||||
// chargeable weight — none of which describes handing somebody a lunch. A
|
||||
// boolean threading through all of that would make both jobs harder to read
|
||||
// and put the collection path, which every logistics rider depends on, one
|
||||
// typo away from a milk-run change.
|
||||
//
|
||||
// What they DO share is the geofence, the location fix and the provider, so
|
||||
// those are reused as-is.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// The rider has reached a **customer's** door on his round.
|
||||
///
|
||||
/// **BACKEND DEPENDENCY — this writes nothing.** There is no route that
|
||||
/// records arrival at a delivery address: `/bookings/:id/reached` belongs to
|
||||
/// the pickup phase and the consignment routes have no arrival event. So this
|
||||
/// verifies he is actually there and returns — the rung exists because the
|
||||
/// rider needs it (it is what turns his Deliver button on at the right door),
|
||||
/// not because the hub can see it.
|
||||
///
|
||||
/// Returning true from a method that sent nothing would normally be a lie;
|
||||
/// it is not one here because the method does not claim to have written.
|
||||
/// See [MilkRun.deliveryArrivalIsLocalOnly].
|
||||
Future<bool> updateDeliveryArrived({
|
||||
required int pickupId,
|
||||
String ridersLat = '0',
|
||||
String ridersLng = '0',
|
||||
String dropLat = '0',
|
||||
String dropLng = '0',
|
||||
}) async {
|
||||
arrivedShimmer.value = true;
|
||||
try {
|
||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
|
||||
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
|
||||
final dLat = double.tryParse(dropLat) ?? 0.0;
|
||||
final dLng = double.tryParse(dropLng) ?? 0.0;
|
||||
|
||||
// Fenced against the DROP, not the pickup. Using the pickup coordinates
|
||||
// here would fence the rider to the kitchen he left an hour ago.
|
||||
if (dLat != 0 && dLng != 0) {
|
||||
final inFence = await _checkGeofence(
|
||||
dLat,
|
||||
dLng,
|
||||
rLat,
|
||||
rLng,
|
||||
'Delivery arrived',
|
||||
);
|
||||
if (!inFence) return false;
|
||||
}
|
||||
|
||||
// The clock the completion record reads, same key the pickup leg uses.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
'pickup_start_$pickupId',
|
||||
DateTime.now().toIso8601String(),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[DELIVERY][ARRIVED][ERROR] $e');
|
||||
return false;
|
||||
} finally {
|
||||
arrivedShimmer.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handed over. `POST /miler/consignments/:consignmentid/deliver`.
|
||||
///
|
||||
/// Keyed on the **consignment**, which is why [consignmentId] is required and
|
||||
/// not derived: it and the booking id come from different sequences, and the
|
||||
/// route answers 404 for the wrong one while the rider is shown success —
|
||||
/// the same trap `bookingassignmentid` sets on the accept side.
|
||||
///
|
||||
/// No OTP: nothing generates one for a milk-run drop and the handler cannot
|
||||
/// verify a code against anything, so sending a placeholder would be
|
||||
/// fabricating proof. See [MilerApi.deliver].
|
||||
Future<bool> updateDeliveredStatus({
|
||||
required int pickupId,
|
||||
required String consignmentId,
|
||||
required String deliveredToName,
|
||||
String ridersLat = '0',
|
||||
String ridersLng = '0',
|
||||
String dropLat = '0',
|
||||
String dropLng = '0',
|
||||
String notes = '',
|
||||
String? proofImage,
|
||||
}) async {
|
||||
lastBlockedReason = null;
|
||||
if (consignmentId.trim().isEmpty) {
|
||||
debugPrint(
|
||||
'[DELIVERED] no consignmentid for pickup $pickupId — refusing',
|
||||
);
|
||||
// Not a wire failure: the app itself refused, and the caller's message
|
||||
// should say what to do rather than suggest a retry.
|
||||
lastBlockedReason =
|
||||
'This order was never picked up on the system — mark it picked '
|
||||
'up first, then deliver.';
|
||||
return false;
|
||||
}
|
||||
|
||||
pickedShimmer.value = true;
|
||||
try {
|
||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
|
||||
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
|
||||
final dLat = double.tryParse(dropLat) ?? 0.0;
|
||||
final dLng = double.tryParse(dropLng) ?? 0.0;
|
||||
|
||||
if (dLat != 0 && dLng != 0) {
|
||||
final inFence = await _checkGeofence(
|
||||
dLat,
|
||||
dLng,
|
||||
rLat,
|
||||
rLng,
|
||||
'Delivered',
|
||||
);
|
||||
if (!inFence) return false;
|
||||
}
|
||||
|
||||
_pickedTime = _formatDateTimeFull(DateTime.now());
|
||||
|
||||
// ── The coordinates must be the DOOR's ──
|
||||
//
|
||||
// The server computes `riderkms` for this leg by haversine from the
|
||||
// pickup coordinates to whatever is sent here, and writes it onto the
|
||||
// earnings record. Sending the pickup's own position would silently zero
|
||||
// the rider's distance — and therefore his pay — for the whole leg.
|
||||
final payload = <String, dynamic>{
|
||||
'pickupid': pickupId,
|
||||
'consignmentid': consignmentId.trim(),
|
||||
'orderstatus': 'delivered',
|
||||
'pickupcustomer': deliveredToName,
|
||||
'pickupedtime': _pickedTime,
|
||||
'riderslat': ll['lat'],
|
||||
'riderslon': ll['lng'],
|
||||
'pickuplat': dropLat,
|
||||
'pickuplong': dropLng,
|
||||
'notes': notes,
|
||||
'dropimage': proofImage ?? '',
|
||||
};
|
||||
|
||||
final resp = await _updateProvider.updatePickup(payload);
|
||||
final ok = _isSuccess(resp);
|
||||
if (!ok) {
|
||||
debugPrint('[DELIVERED][FAILED] resp=${jsonEncode(resp)}');
|
||||
// ── The server's refusal is the rider's message ──
|
||||
//
|
||||
// This swallowed the response and the UI fell back to "Could not
|
||||
// record delivered — try again", which reads as a network blip and
|
||||
// invites a retry that will refuse identically. The backend's message
|
||||
// names the actual condition ("not out for delivery", "not found"),
|
||||
// which is the difference between a rider retrying forever and a
|
||||
// rider knowing what to tell the hub.
|
||||
final serverMsg = (resp?['message'] ?? '').toString().trim();
|
||||
// ── `not out for delivery` is NOT a hub-release message ──
|
||||
//
|
||||
// It was mapped to "the hub hasn't released this yet", which is one of
|
||||
// the two opposite situations the backend spells that way — the other
|
||||
// is **already delivered**, which is what a rider actually meets. The
|
||||
// question is now answered before the call, from the consignment's own
|
||||
// history, so this branch no longer guesses: whatever the server says
|
||||
// is passed through as the server's own words.
|
||||
if (serverMsg.isNotEmpty && serverMsg.length < 140) {
|
||||
lastBlockedReason = 'The office refused this delivery: $serverMsg';
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
} catch (e) {
|
||||
debugPrint('[DELIVERED][ERROR] $e');
|
||||
return false;
|
||||
} finally {
|
||||
pickedShimmer.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Rejected (remove from queue)
|
||||
Future<bool> updateRejectedStatus({
|
||||
required int pickupId,
|
||||
@@ -1415,6 +1882,9 @@ class PickupsController extends GetxController
|
||||
String ridersLng = '0',
|
||||
String notes = '',
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'rejected');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||
final payload = <String, dynamic>{
|
||||
@@ -1461,7 +1931,11 @@ class PickupsController extends GetxController
|
||||
double actualKms = 0.0,
|
||||
bool wasSkipped = false,
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'cancelled');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
lastBlockedReason = null;
|
||||
final now = DateTime.now();
|
||||
_cancelledTime = _formatDateTimeFull(now);
|
||||
|
||||
@@ -1746,6 +2220,9 @@ class PickupsController extends GetxController
|
||||
String ridersLng = '0',
|
||||
String notes = '',
|
||||
}) async {
|
||||
final mock = _mockStatus(pickupId, 'skipped');
|
||||
if (mock != null) return mock;
|
||||
|
||||
try {
|
||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||
final skippedTime = _formatDateTimeFull(DateTime.now());
|
||||
|
||||
@@ -29,8 +29,7 @@ class RewardsController extends GetxController {
|
||||
// earnings.
|
||||
final res = await MilerApi.earnings(period: 'monthly');
|
||||
if (res.ok) {
|
||||
totalPoints.value =
|
||||
int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
|
||||
totalPoints.value = int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
|
||||
} else {
|
||||
error.value = res.message.isEmpty
|
||||
? "Couldn't load your points"
|
||||
|
||||
@@ -169,7 +169,6 @@ class RiderLogController extends GetxController
|
||||
required String latitude,
|
||||
required String longitude,
|
||||
}) async {
|
||||
|
||||
final payload = {
|
||||
"breakid": breakid,
|
||||
"logid": logid,
|
||||
@@ -471,7 +470,6 @@ class RiderLogController extends GetxController
|
||||
payload.remove('lastname');
|
||||
}
|
||||
|
||||
|
||||
await _saveToOfflineQueue('/miler/logs', payload);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -488,15 +486,6 @@ class RiderLogController extends GetxController
|
||||
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
|
||||
if ((userid ?? 0) == 0) return false;
|
||||
|
||||
// Demo mode: the mocked login flow in auth.dart stores a fake rider id
|
||||
// (9999) that the live backend rejects, so the real updateriderlog call
|
||||
// always fails. Toggle duty locally and report success so the demo
|
||||
// "Slide to Start Duty" works without a real account or server call.
|
||||
if (userid == 9999) {
|
||||
await prefs.setInt('onduty', on ? 1 : 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
final loc = await _ensureLatLng('0', '0');
|
||||
final payload = RiderUpdate(
|
||||
userid: userid,
|
||||
@@ -898,7 +887,6 @@ class RiderLogController extends GetxController
|
||||
final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16
|
||||
final breakstart = _formatTime(now); // e.g. 16:36:16
|
||||
|
||||
|
||||
// Build payload with all required fields in the exact format the API expects
|
||||
final payload = <String, dynamic>{
|
||||
"breakid": localBreakId,
|
||||
@@ -1074,7 +1062,6 @@ class RiderLogController extends GetxController
|
||||
final iso = _formatDateTimeFull(now);
|
||||
final loginTime = _formatTime(now);
|
||||
|
||||
|
||||
// ✅ Check if there are active pickup to set status
|
||||
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
|
||||
final String riderStatus = hasActivePickups ? 'active' : 'idle';
|
||||
|
||||
Reference in New Issue
Block a user