Files
doormile_milderapp/lib/views/Dashboard/pickups/pickups.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

3372 lines
139 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast
library;
// ── Dead visual effects, removed ──
//
// This file imported `confetti`, `scratcher`, `circular_countdown_timer`,
// `slider_button_lite` and `lottie` alongside the delivery widgets — the
// remains of a celebratory completion flow. Every one of them had **zero**
// references left in the file; they were kept alive only by their import
// lines, which is how a scratch-card and a confetti burst stay one wiring
// mistake away from a field-service queue.
//
// The packages stay in `pubspec.yaml` because other screens use some of them;
// what is removed here is this file's claim on them.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart' show kDebugMode, setEquals;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/views/helpers/widgets/miler_map.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/helpers/poll_policy.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
import 'package:miler/views/Dashboard/home/homepage_banner.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:miler/providers/pickup/pickup_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/controllers/pickups_controller.dart';
import 'package:miler/controllers/duty_controller.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:circular_countdown_timer/circular_countdown_timer.dart';
import 'dart:math' as math;
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
import 'package:miler/controllers/riderlog.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/proof_store.dart';
import 'package:miler/views/Dashboard/pickups/delivery_proof_page.dart';
import 'package:miler/data/mutation_guard.dart';
import 'package:miler/data/stop_compliance.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:scratcher/scratcher.dart';
import 'package:confetti/confetti.dart';
import 'package:miler/data/service_profile.dart';
// The two pages the logistics arrival opens: proof of what is being collected,
// then the shipment desk that establishes where it is going and what it costs.
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
import 'package:miler/views/Dashboard/pickups/shipment_capture.dart';
import 'package:miler/views/Dashboard/pickups/shipment_review.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/order_events.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/stop_area.dart';
import 'package:miler/data/stop_contact.dart';
import 'package:miler/data/work_domain.dart';
import 'package:miler/data/work_repository.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/assignment_lookup.dart';
import 'package:miler/utils/external_navigation.dart';
part 'card.dart';
part 'map.dart';
part 'pip.dart';
part 'sheet.dart';
part 'map_btn.dart';
part 'multi_map.dart';
part 'done.dart';
part 'skip_sheet.dart';
part 'delivery_actions.dart';
// `part '../Cart/cartpage.dart';` — REMOVED FROM THE BUILD.
//
// 1,070 lines of a Cart screen that nothing in the app ever instantiated:
// `Cartpage` had no route, no tab and no call site. It compiled into every
// release, carried its own 30-second poller and its own copy of the stop card,
// and — because it was never on screen — quietly rotted out of step with the
// Bookings list it duplicated.
//
// The file is still on disk. Delete it once this repo is under version control
// and you can get it back; until then it is simply out of the binary.
/// Helper function to launch phone dialer - works in both debug and release builds
/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch
Future<bool> launchPhoneDialer(String phoneNumber) async {
try {
// Sanitize phone number: keep only digits and '+'
final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), '');
if (phone.isEmpty) {
debugPrint(
'[PHONE] Empty phone number after sanitization, skipping dial',
);
return false;
}
final Uri uri = Uri(scheme: 'tel', path: phone);
// Try canLaunchUrl first (works in debug, may fail in release)
bool canLaunch = false;
try {
canLaunch = await canLaunchUrl(uri);
debugPrint('[PHONE] canLaunchUrl result: $canLaunch');
} catch (e) {
debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e');
// Continue anyway - launch might still work
}
// Always attempt to launch, even if canLaunchUrl returned false
// Using LaunchMode.platformDefault is often safer for system intents like dialing
try {
final launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
if (launched) {
debugPrint('[PHONE] Successfully launched dialer for: $phone');
return true;
} else {
debugPrint('[PHONE] launchUrl returned false for: $phone');
}
} catch (e) {
debugPrint('[PHONE] Failed to launch dialer: $e');
}
return false;
} catch (e) {
debugPrint('[PHONE] Error in launchPhoneDialer: $e');
return false;
}
}
class MyPickups extends StatefulWidget {
const MyPickups({super.key});
@override
State<MyPickups> createState() => _MyPickupsState();
/// Forgets a local skip, so the stop stops being re-stamped 'skipped' on the
/// next poll.
///
/// Keyed the way this screen keys everything: pickup id when there is one,
/// order id otherwise.
static void forgetSkip({String pickupId = '', String orderId = ''}) {
final key = pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
_MyPickupsState._forgetSkip(key);
}
/// Puts a skipped stop back into the working queue.
///
/// A skip parks a stop for a return visit, so it waits on **Activity** — the
/// record of what has happened today — rather than among the live work on
/// Bookings. Resuming is an accept on the wire, the same call the accept bar
/// makes, plus clearing the skip in both places that hold one:
///
/// • [_skippedOrdersCache], or the 3s poller would re-stamp 'skipped' onto
/// the stop within seconds and the resume would silently undo itself;
/// • the persistent store, which is what Home and Activity read.
///
/// Optimistic, like every other decision in this app: the local record is
/// written whatever the network says, and the poll catches up. Returns false
/// only when there is no order id to act on.
///
/// Lives here rather than on the screen that shows the control because two
/// screens have now owned that control in turn — Home, then Activity — and
/// the logic moved with it both times, cache-clearing included.
static Future<bool> resumeSkippedStop(Map<String, dynamic> stop) async {
final orderId = (stop['orderid'] ?? '').toString();
if (orderId.isEmpty) return false;
forgetSkip(pickupId: (stop['pickupid'] ?? '').toString(), orderId: orderId);
// ── A delivery park is taken back locally, because that is all it was ──
//
// The accept below is the right call for a **pickup** the rider walked
// away from: the booking really did go back to undecided, and resuming is
// a genuine re-acceptance.
//
// It is the wrong call for a stop skipped at a customer's door. That
// booking is `Converted_To_Consignment` — collected, converted, past the
// accept rung entirely — and posting an accept against it is the wrong
// object at the wrong point in its life. The consignment was never closed
// by the skip (see `closeDelivery`); the only thing standing between the
// rider and a second attempt is the local park, so removing it *is* the
// resume. No server call, because there is no server state to undo.
if (_isDeliveryLegStop(stop)) {
await removeSkippedBookings([orderId]);
await clearOrderEvents(orderId);
stop['orderstatus'] = 'outfordelivery';
if (Get.isRegistered<PickupsController>()) {
Get.find<PickupsController>().triggerRefresh();
}
debugPrint('[RESUME] $orderId un-parked on the delivery leg');
return true;
}
final dc = Get.isRegistered<PickupsController>()
? Get.find<PickupsController>()
: Get.put(PickupsController(), permanent: true);
String lat = '0';
String lng = '0';
try {
final pos =
await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
),
);
lat = pos.latitude.toStringAsFixed(6);
lng = pos.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[RESUME] No location for $orderId: $e');
}
try {
await dc.updateAcceptedStatus(
pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0,
orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0,
ridersLat: lat,
ridersLng: lng,
);
} catch (e) {
debugPrint('[RESUME] $orderId status call failed: $e');
}
final resumed = Map<String, dynamic>.from(stop)
..['orderstatus'] = 'accepted';
await removeSkippedBookings([orderId]);
await addAcceptedBookings([resumed]);
// A resumed skip is a genuinely new attempt at the same door, so the first
// attempt's clocks describe a visit that has been superseded. Same argument
// `addCompletedBookings` makes when it clears the keys it has just read.
await clearOrderEvents((resumed['orderid'] ?? '').toString());
stop['orderstatus'] = 'accepted';
dc.triggerRefresh();
return true;
}
/// True when this stop's remaining work is a **delivery**, not a collection.
///
/// Asked of the row rather than of the screen, because resume is offered
/// from Activity, where there is no queue to consult: a stop that carries a
/// consignment, or whose status is a delivery rung, is past the collection
/// half of its life whatever tab it is being looked at from.
static bool _isDeliveryLegStop(Map<String, dynamic> stop) {
final consignment = (stop['consignmentid'] ?? '').toString().trim();
if (consignment.isNotEmpty && consignment != '0') return true;
if (consignmentStateFromRaw(stop['consignmentstatus']) !=
ConsignmentState.unknown) {
return true;
}
final status = stopStatusFromRaw(stop['orderstatus']);
return status.isDeliveryLeg || status.isPicked;
}
// ✅ Public static method to navigate to pickup map screen from outside (e.g., home page)
static Future<void> navigateToPickupMap(
BuildContext context,
Map<String, dynamic> pickup,
) async {
await openScreen(
context,
_PickupMapScreen(
pickup: pickup,
parentState: null, // No parent state when called from outside
),
swipeToGoBack: false,
);
}
}
class _MyPickupsState extends State<MyPickups>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
final PickupProvider _provider = PickupProvider();
final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider();
List<Map<String, dynamic>> _picked = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _activePickups =
<Map<String, dynamic>>[]; // Track active pickup for banner
StreamSubscription<void>? _pollerSubscription;
/// What [_syncPolling] last set, so an unchanged interval is a no-op.
Duration? _currentPollInterval;
/// False while the app is backgrounded — see [pollInterval].
bool _pollForeground = true;
/// Whether the rider is on duty. Drives the idle poll rate.
bool _onDuty = true;
/// Mirrors the wakelock so it is only toggled on a real change.
bool _wakelockHeld = false;
/// Set when a fetch fails with nothing to fall back on. An empty queue after
/// a network error used to render as the "You're all caught up" celebration —
/// the app congratulating the rider on work it had failed to ask for.
bool _fetchFailed = false;
bool _fetching = false;
/// False until the first fetch has come back, win or lose.
///
/// Without it this screen answered "how many bookings do I have?" before it
/// had asked: an empty `_picked` renders the "You're all caught up" Lottie,
/// so every cold open of the Bookings tab congratulated the rider on an empty
/// queue for as long as the network took, then replaced it with a list of
/// stops. Empty and not-yet-known are different states and only one of them
/// is good news.
bool _firstLoadDone = false;
Position? _currentLocation;
final Map<String, Timer> _pickupTimers = <String, Timer>{};
final Map<String, Map<String, dynamic>> _pickupBasePayload =
<String, Map<String, dynamic>>{};
// Preserve original step numbers so they don't change when pickup are completed
final Map<String, int> _preservedStepNumbers = <String, int>{};
String? _activePickupOrderId;
// Order keys the rider just finished (picked up / cancelled). We keep them
// here so the 3s poller can't resurrect a completed stop before the backend
// reflects it — otherwise a done pickup keeps re-appearing as "active" and
// blocks the next one ("Complete current stop first").
final Set<String> _completedOrderKeys = <String>{};
// Cache for skipped bookings.
//
// Static, and reachable from outside this State, because a skip is now
// visible on two screens: Bookings records it, and Home lists the skipped
// stop until the rider resumes it. Resuming happens on Home — so Home has to
// be able to clear the entry, or this cache would re-stamp 'skipped' onto the
// stop on the very next poll and the resume would silently undo itself. The
// Bookings tab lives inside an IndexedStack and is never disposed, so a
// per-State map really would outlive the resume.
static final Map<String, String> _skippedOrdersCache = {};
static final Map<String, int> _skippedOrderTimestamps = {};
static void _forgetSkip(String key) {
_skippedOrdersCache.remove(key);
_skippedOrderTimestamps.remove(key);
}
Future<void> _saveSkippedOrdersCache() async {
// Implementation can be empty if we rely on API now,
// or strictly local. For now, we'll keep it simple or empty
// to satisfy the interface expected by child widgets.
// If child widgets call this, they expect it to exist.
}
@override
bool get wantKeepAlive => true;
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
const double R = 6371.0;
final double dLat = _toRadians(lat2 - lat1);
final double dLon = _toRadians(lon2 - lon1);
final double a =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_toRadians(lat1)) *
math.cos(_toRadians(lat2)) *
math.sin(dLon / 2) *
math.sin(dLon / 2);
final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return R * c;
}
double _toRadians(double degrees) {
return degrees * math.pi / 180.0;
}
int _getStepNumber(Map<String, dynamic> Booking) {
final dynamic raw = Booking['step'] ?? Booking['Step'];
final int step = raw == null
? 0
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
return step;
}
String _getOrderKey(Map<String, dynamic> Booking) {
// Use pickupId as primary key, fallback to orderId
final pickupId = (Booking['pickupid'] ?? '').toString();
final orderId = (Booking['orderid'] ?? '').toString();
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
}
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
final orderKey = _getOrderKey(Booking);
// If we have a preserved step number, use it; otherwise use current API step
if (_preservedStepNumbers.containsKey(orderKey)) {
return _preservedStepNumbers[orderKey]!;
}
final currentStep = _getStepNumber(Booking);
// Preserve the step number if it's valid (> 0)
if (currentStep > 0) {
_preservedStepNumbers[orderKey] = currentStep;
}
return currentStep;
}
/// Today's finished stops, newest last, for the rail.
///
/// Refreshed with every fetch. See [_railStops] for why the rail needs them.
List<Map<String, dynamic>> _completedToday = <Map<String, dynamic>>[];
// ══════════════════════════════════════════════════════════════════════
// THE MILK RUN'S ROUND
//
// This tab is where a milk-run rider's load lands and where his round
// starts. Everything below is read on every fetch rather than held from the
// last action, so the screen is right after a restart mid-shift too.
// ══════════════════════════════════════════════════════════════════════
/// Every stop the hub assigned today, unfiltered.
///
/// The gate needs the whole day, not this tab's list: "is the load complete?"
/// is a question about the orders still sitting on **Home** waiting to be
/// collected, and those are precisely the ones this screen filters out.
List<Map<String, dynamic>> _allDayStops = <Map<String, dynamic>>[];
Set<String> _acceptedIds = <String>{};
Set<String> _collectedIds = <String>{};
Set<String> _outForDeliveryIds = <String>{};
Set<String> _notLoadedIds = <String>{};
Set<String> _rejectedIds = <String>{};
/// Whether this rider's line delivers what it collects.
bool get _isMilkRun => ServiceProfile.active.deliversToCustomer;
/// What the rail draws: the whole run, finished stops included.
///
/// ── Why this is not [_visibleStops] ──
///
/// It was, and that is why the rail never moved. `_visibleStops` is the list
/// of stops still to *work*, and a completed stop is removed from it the
/// instant it is confirmed — so the rail was handed a set in which nothing
/// could ever be `done`. Every node stayed grey, the scooter sat on the first
/// one all day, and finishing a stop made the rail one node SHORTER instead
/// of one node greener: the rider's only measure of progress went backwards
/// as he made it.
///
/// The completed stops come from the same store the Activity tab reads, so
/// the two agree about what was finished, and they lead because they were
/// done first. The open ones follow in the hub's order.
List<Map<String, dynamic>> get _railStops {
final open = _visibleStops;
if (_completedToday.isEmpty) return open;
final openKeys = open.map(_getOrderKey).toSet();
final done = _completedToday
.where((o) => !openKeys.contains(_getOrderKey(o)))
.toList()
.reversed // the store hands them back newest first
.toList();
return [...done, ...open];
}
/// Progress state for every stop on the accepted trip, in the admin's fixed
/// order — what drives the red→green rail at the top of Bookings.
///
/// Exactly one stop is ever [StopProgress.current]: whichever the rider is
/// physically on, or failing that the first stop still to be done. Skipped
/// stops keep their own state so they read as unfinished business rather
/// than either done or next.
///
/// The per-stop mapping is [stopProgressFor], which is pure and lives in
/// `trip.dart` so it can be tested — this State cannot be pumped. Read its
/// doc for why "physically on" has to be asked in both legs' vocabulary.
List<StopProgress> _tripProgress() {
final states = [
for (final stop in _railStops) stopProgressFor(stopStatusOf(stop)),
];
// No stop is live: promote the first pending one so the rail always shows
// the rider where he is in the sequence.
if (!states.contains(StopProgress.current)) {
final next = states.indexOf(StopProgress.pending);
if (next >= 0) states[next] = StopProgress.current;
}
return states;
}
/// Stage-by-stage counts from the last fetch. Debug only — surfaced on the
/// empty state so "no cards" reports a cause instead of a symptom.
String _fetchDiag = '';
/// Which of the day's trips each order belongs to, 1-based.
///
/// ── Why this is computed from the whole day and not from this list ──
///
/// Bookings shows only accepted stops, so grouping *its* list would number
/// whatever the rider happened to take as "Trip 1" — a miler who declined the
/// morning slot and took the afternoon one would be told he is riding Trip 1
/// while the hub, Home and his own supervisor all call it Trip 2. The number
/// only means something against the day the hub assigned, so it is derived
/// from every stop the app knows about today — pending, accepted, finished —
/// before any of this screen's filtering.
///
/// Rebuilt on each fetch, so a trip appearing mid-shift renumbers everywhere
/// at once rather than only here.
Map<String, int> _tripNumberByOrderId = const {};
/// Groups every stop the app knows about today and hands back order id → trip
/// number. Deduped by order id first: the same stop arrives from up to three
/// sources per fetch, and counting it twice would not change its trip but
/// would waste the grouping's arithmetic on copies.
Map<String, int> _numberTrips(List<Map<String, dynamic>> allToday) {
final byId = <String, Map<String, dynamic>>{};
for (final s in allToday) {
final id = (s['orderid'] ?? '').toString();
if (id.isEmpty) continue;
byId.putIfAbsent(id, () => s);
}
if (byId.isEmpty) return const {};
final trips = Trip.groupIntoTrips(
byId.values.toList(),
hubLat: _currentLocation?.latitude,
hubLng: _currentLocation?.longitude,
);
return <String, int>{
for (var i = 0; i < trips.length; i++)
for (final id in trips[i].orderIds) id: i + 1,
};
}
/// The trip a stop belongs to, or null when the day's shape is not known yet.
int? _tripNumberOf(Map<String, dynamic> stop) =>
_tripNumberByOrderId[(stop['orderid'] ?? '').toString()];
/// The trip the rider is riding right now: the one owning the stop he is on,
/// or the next one he will reach. Null when nothing is outstanding.
int? get _currentTripNumber {
final stops = _visibleStops;
if (stops.isEmpty) return null;
// The live stop first — it is the one he is standing at.
for (final s in stops) {
if (stopStatusOf(s).isActive) {
final n = _tripNumberOf(s);
if (n != null) return n;
}
}
for (final s in stops) {
final n = _tripNumberOf(s);
if (n != null) return n;
}
return null;
}
/// Every trip number present in the outstanding queue, in order. More than one
/// means the rider is carrying work from two slots at once, which he is
/// entitled to know before he plans his afternoon.
List<int> get _queuedTripNumbers {
final seen = <int>{};
for (final s in _visibleStops) {
final n = _tripNumberOf(s);
if (n != null) seen.add(n);
}
final list = seen.toList()..sort();
return list;
}
/// The accepted stops, grouped into the same trips Home shows.
///
/// Still grouped, because grouping is what puts the stops in the hub's
/// intended riding order. Bookings no longer *shows* the grouping — see
/// [_visibleStops].
List<Trip> get _bookingTrips => Trip.groupIntoTrips(
_picked,
hubLat: _currentLocation?.latitude,
hubLng: _currentLocation?.longitude,
);
/// Every accepted stop, in riding order, as one continuous list.
///
/// ── Why the Trip 1 · 2 · 3 tabs are gone from this screen ──
///
/// They were here to mirror Home, and mirroring was the wrong goal. The two
/// screens answer different questions:
///
/// • **Home is a decision screen.** The rider is choosing which of the day's
/// three trips to take, so the trips have to be separable — he accepts one
/// slot's route and declines another's. Tabs are right there.
///
/// • **Bookings is a work screen.** Everything on it is already accepted;
/// the only question left is *which stop do I ride to next*. That question
/// has one answer across the whole day, and it does not care which slot the
/// stop was assigned in.
///
/// The tabs actively got in the way of it. Finishing the last stop of Trip 1
/// left the rider on a tab reading "Trip 1 is done" with his next stop hidden
/// behind a tap he had no reason to know he needed — the screen looked
/// finished while work remained. And a stop could be *invisible* on the screen
/// whose entire job is to show him his work, purely because a tab was closed.
///
/// One list, in order, no hidden state.
List<Map<String, dynamic>> get _visibleStops => [
for (final trip in _bookingTrips) ...trip.stops,
];
/// Which bag each order is in, as recorded at the counter.
///
/// Read once per refresh rather than per card: it is one preferences hit, and
/// a `FutureBuilder` per row would blink a bagless card in on every rebuild.
Map<String, String> _bagLabels = const {};
Future<void> _loadBagLabels() async {
final labels = await getBagLabels();
if (!mounted) return;
setState(() => _bagLabels = labels);
}
/// ── The queue, in the order it will be worked ──
///
/// A delivery list where every card looks the same makes the rider decide
/// which one is next — a decision the app already knows the answer to. So the
/// list is cut into three, and only the first section is live:
///
/// **NOW** — the one stop he is working. The only enabled card.
/// **UP NEXT** — the one after it, so he can see where he is heading.
/// **LATER** — the rest, in order.
///
/// Skipped stops sit above NOW, under their own heading: they are earlier in
/// route order and still resumable, and burying them under LATER would hide
/// work he has already been to once.
///
/// This adds no new sequencing rule. Which card is live was already decided
/// here — the first non-skipped stop, and nothing while another is running —
/// and the sections simply say out loud what that logic already meant.
List<_QueueRow> _queueRows() {
// ── `isWorkComplete`, not `isPicked` ──
//
// This dropped every stop whose raw rung was `picked`. On a parcel route
// that is correct by accident — collection IS the end of the job there. On
// a **milk run** `picked` is the middle of the morning: the rider is
// holding fifteen lunches and has not delivered one of them.
//
// So collecting a crate at the kitchen made the card vanish off this screen
// while the rail above it — which correctly reads the profile-aware
// predicate — kept drawing the stop as a node. That is the exact symptom:
// a rail with a stop on it and no card underneath.
//
// [StopStatusX.isWorkComplete] is the one that asks "should this still be
// worked today?", and its own doc says to read it here. Nothing else on
// this screen was using the raw rung.
final workable = [
for (final s in _visibleStops)
if (!stopStatusOf(s).isCancelled && !stopStatusOf(s).isWorkComplete) s,
];
if (workable.isEmpty) return const [];
final hasActive = _hasActivePickup();
final liveIndex = workable.indexWhere((s) => !stopStatusOf(s).isSkipped);
final rows = <_QueueRow>[];
var skippedSeen = false;
var laterSeen = false;
for (var i = 0; i < workable.length; i++) {
final stop = workable[i];
final skipped = stopStatusOf(stop).isSkipped;
final before = i < (liveIndex < 0 ? workable.length : liveIndex);
QueueDensity density;
if (skipped) {
density = QueueDensity.skipped;
} else if (i == liveIndex && !hasActive) {
density = QueueDensity.now;
} else if (i == liveIndex || i == liveIndex + 1) {
// While another stop is already running there is no live card at all —
// the one he is working is on its own screen — so the head of the queue
// reads as "next" rather than pretending to be actionable.
density = QueueDensity.next;
} else {
density = QueueDensity.later;
}
if (skipped && !skippedSeen && before) {
skippedSeen = true;
rows.add(const _QueueRow.heading('SKIPPED'));
} else if (density == QueueDensity.now) {
rows.add(const _QueueRow.heading('NOW'));
} else if (!skipped && i == liveIndex + (hasActive ? 0 : 1)) {
rows.add(const _QueueRow.heading('UP NEXT'));
} else if (density == QueueDensity.later && !laterSeen) {
laterSeen = true;
rows.add(_QueueRow.heading('LATER', count: workable.length - i));
}
rows.add(
_QueueRow.stop(
stop: stop,
density: density,
displayStep: _getDisplayStepNumber(
_visibleStops,
_visibleStops.indexOf(stop),
),
bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '',
reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '',
),
);
}
return rows;
}
/// Puts a skipped stop back in the queue.
///
/// The work itself is [MyPickups.resumeSkippedStop] — a static, because three
/// screens have owned this control in turn and the logic moved with it every
/// time. This is only the screen-local half: refresh the list so the row
/// leaves SKIPPED and rejoins the queue in route order.
Future<void> _resumeSkipped(Map<String, dynamic> stop) async {
final ok = await MyPickups.resumeSkippedStop(stop);
if (!mounted || !ok) return;
AppFeedback.successGlobal('Back in your queue');
await _fetchPicked(force: true);
}
/// Shown when the rider has accepted work but none of it is outstanding.
///
/// With the trip tabs gone this is a much simpler state than it was: there is
/// no longer a "this tab is empty but the others are not" case to explain,
/// because there are no tabs to be on the wrong one of. Either there is work
/// on this screen or the day is done.
///
/// Must stay scrollable — it is the child of a `RefreshIndicator`, and
/// pull-to-refresh is the rider's way out of it.
Widget _buildTripTabEmptyState() {
return ListView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(24.w, 48.h, 24.w, 24.h),
children: [
Center(
child: Container(
width: 62.w,
height: 62.w,
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
border: Border.all(color: ColorConstants.borderStrong),
),
child: Icon(
LucideIcons.circleCheck,
size: 28.sp,
color: ColorConstants.acceptGreen,
),
),
),
SizedBox(height: 14.h),
Text(
'All your stops are done',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
'Anything you accept on Home lands here. Pull down to refresh.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
_fetchDiagLine(),
],
);
}
/// The last fetch's stage counts, shown under an empty state in debug only.
/// Never compiled into release — [kDebugMode] is a const, so the whole widget
/// folds away.
Widget _fetchDiagLine() {
if (!kDebugMode || _fetchDiag.isEmpty) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.only(top: 18.h),
child: Text(
'debug · $_fetchDiag\n'
'${_bookingTrips.length} trip(s) · showing ${_visibleStops.length}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10.sp,
height: 1.5,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
);
}
Widget _buildTripRail() {
// The whole run, finished stops included — see [_railStops]. Drawn as long
// as *anything* happened today, so a rider who has just closed his last
// stop still sees the full green rail rather than the strip vanishing at
// the moment it finally reads 100%.
final railStops = _railStops;
if (railStops.isEmpty) return const SizedBox.shrink();
final states = _tripProgress();
final trip = Trip.fromStops(
id: 'active',
stops: railStops,
hubLat: _currentLocation?.latitude,
hubLng: _currentLocation?.longitude,
);
// Time left is only the work still outstanding — counting finished stops
// would keep the estimate flat all day and make it useless.
var remaining = Duration.zero;
for (var i = 0; i < railStops.length && i < states.length; i++) {
if (states[i] == StopProgress.done) continue;
remaining += Trip.serviceTimeFor(railStops[i]);
remaining += trip.travelTimeToStop(
i,
hubLat: _currentLocation?.latitude,
hubLng: _currentLocation?.longitude,
// Door to door on a round — the walk over the pickup pairs was a
// tour of kitchens already behind him. See [Trip.travelTimeToStop].
deliveryLeg: _isMilkRun,
);
}
// ── Name the trip he is riding ──
//
// The rail headline read "10:00 AM – 1:00 PM", or "Your route" when the
// backend sent no slot — neither of which answers the question the rider
// actually asks himself on this screen, which is *which of today's trips am
// I on*. He is given three, they are numbered on Home, the hub refers to
// them by number on the phone, and this screen was the one place in the
// flow that would not say.
//
// The number leads and the window follows it, because the number is the
// shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM".
final tripNo = _currentTripNumber;
final window = trip.slotStart != null ? trip.slotLabel : null;
final label = tripNo == null
? (window ?? 'Your route')
: window == null
? 'Trip $tripNo'
: 'Trip $tripNo · $window';
return TripProgressRail(
progress: states,
slotLabel: label,
remaining: remaining > Duration.zero ? remaining : null,
);
}
String _distanceKmDisplay(Map<String, dynamic> m) {
final String apiKmsStr = (m['kms'] ?? '').toString().trim();
final double apiKms = double.tryParse(apiKmsStr) ?? 0.0;
if (apiKms > 0) {
return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0);
}
final double rLat = _parseD(m['riderslat']);
final double rLon = _parseD(m['riderslon']);
final double dLat = _parseD(m['droplat'] ?? m['pickuplat']);
final double dLon = _parseD(m['droplon'] ?? m['pickuplong']);
if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) {
final double km = _haversineKm(rLat, rLon, dLat, dLon);
return km.toStringAsFixed(km < 10 ? 1 : 0);
}
final double pLat = _parseD(m['pickuplat']);
final double pLon = _parseD(m['pickuplon']);
if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) {
final double km = _haversineKm(pLat, pLon, dLat, dLon);
return km.toStringAsFixed(km < 10 ? 1 : 0);
}
return '0';
}
double _calculateDistanceFromCurrent(Map<String, dynamic> Booking) {
if (_currentLocation == null) return double.infinity;
final double dropLat = _parseD(Booking['droplat'] ?? Booking['pickuplat']);
final double dropLon = _parseD(Booking['droplon'] ?? Booking['pickuplong']);
if (dropLat == 0 || dropLon == 0) return double.infinity;
return _haversineKm(
_currentLocation!.latitude,
_currentLocation!.longitude,
dropLat,
dropLon,
);
}
/// Which rule ordered the queue the rider is looking at.
///
/// Shown on the list — see [_orderNote] — because a queue ordered by the
/// app's own guess and one ordered by the hub's solved route look identical,
/// and only one of them is a plan anyone else knows about.
RouteOrderSource _orderSource = RouteOrderSource.backendOrder;
/// Puts the delivery queue in the order it is to be worked.
///
/// ── What this used to do, and why it was wrong ──
///
/// It split the list in two: stops carrying a `step` were sorted by it, and
/// everything else was sorted **by straight-line distance from the rider's
/// current GPS fix** and appended. Two failures in one.
///
/// The first is that it produced a list that was neither order — half the
/// hub's route, half the app's. The second is that in practice it was
/// *always* the second half: `step` reached the app only through
/// `GET /miler/assignments`, which is deliberately the active queue
/// (`Assigned`/`Accepted`), and a booking leaves those states the instant it
/// is collected. Every delivery therefore arrived here unsequenced and was
/// re-planned nearest-first, while the hub believed its route was being
/// followed and the rider had no way to know it was not.
///
/// The sequence now comes down on the booking row itself, and the rule is
/// [RouteOrder]'s — the same one the pickup leg uses. Distance is the last
/// resort and never mixes with an assigned order.
List<Map<String, dynamic>> _sortOrders(List<Map<String, dynamic>> bookings) {
if (bookings.isEmpty) return bookings;
final (ordered, source) = RouteOrder.sort(
bookings,
bookedTimeOf: (o) => DateTime.tryParse(
(o['expected_pickup_time'] ?? o['expectedpickuptime'] ?? o['eta'] ?? '')
.toString(),
),
// Offered, not preferred: [RouteOrder.sort] reaches for it only when no
// stop in the set carries a sequence and none carries a booked time.
distanceTo: _currentLocation == null
? null
: (o) => _calculateDistanceFromCurrent(o),
);
_orderSource = source;
if (!source.isAdmin) {
RouteOrder.logUnsequenced('deliveries', bookings.length);
}
return ordered;
}
int _getDisplayStepNumber(List<Map<String, dynamic>> allOrders, int index) {
final Booking = allOrders[index];
// Use preserved step number if available, otherwise get current step
final step = _getPreservedOrCurrentStep(Booking);
if (step > 0) return step;
// For bookings without step numbers, calculate based on bookings that have step numbers
final ordersWithStep = allOrders
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
return ordersWithStep + (index - ordersWithStep) + 1;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadDutyState();
// Listen for external refresh triggers
try {
final dc = Get.isRegistered<PickupsController>()
? Get.find<PickupsController>()
: Get.put(PickupsController());
ever(dc.refreshTrigger, (_) {
if (mounted) {
debugPrint(
'[MYPICKUPS] External trigger -> Refreshing picked bookings',
);
_fetchPicked(force: true);
}
});
} catch (_) {}
// ── Re-read whenever this tab comes forward ──
//
// The shell keeps all four tabs alive in an `IndexedStack`, so arriving
// here is not a build — it is an index change, and nothing about it asks
// this screen for fresh data. The poller does eventually, up to three
// seconds later, which is exactly long enough for a rider who has just
// accepted a stop on Home to arrive, find it missing, and conclude the
// accept failed. Activity carries the same listener for the same reason.
BottomPage.currentTab.addListener(_onTabChanged);
// Run these in parallel
_restoreSkips();
_restoreClosedToday();
_restoreActivePickup();
_initializeLocation(); // Don't block
_fetchPicked();
_loadBagLabels();
_startPollingStream();
}
/// This screen's slot in the shell. See [BottomPage.goToTab].
static const int _bookingsTabIndex = 1;
void _onTabChanged() {
if (BottomPage.currentTab.value == _bookingsTabIndex && mounted) {
_fetchPicked(force: true);
}
}
/// Re-loads today's skips into [_skippedOrdersCache] after a cold start.
///
/// The cache is static but not persistent, so killing the app used to lose
/// every skip taken that shift: the stop came back from the queue with
/// whatever status the backend last recorded and dropped straight back into
/// the working list, ahead of stops the rider had not skipped. The store that
/// Activity reads is the same one, so both screens now agree after a restart.
Future<void> _restoreSkips() async {
try {
final skipped = await getSkippedBookings();
for (final b in skipped) {
final key = _getOrderKey(b);
_skippedOrdersCache[key] = (b['skipreason'] ?? '').toString();
_skippedOrderTimestamps[key] =
DateTime.tryParse(
(b['skippedat'] ?? '').toString(),
)?.millisecondsSinceEpoch ??
DateTime.now().millisecondsSinceEpoch;
}
if (skipped.isNotEmpty) {
debugPrint('[MYPICKUPS] Restored ${skipped.length} skip(s) from store');
}
} catch (e) {
debugPrint('[MYPICKUPS] Could not restore skips: $e');
}
}
/// Re-loads the stops already closed today, so a restart cannot put one
/// back on this tab.
///
/// ── The reappearing skip ──
///
/// [_completedOrderKeys] is what stops the 3-second poller resurrecting a
/// stop the rider has just finished, and it was memory-only. That is enough
/// for a delivery, because the backend agrees within a poll or two — the
/// consignment goes `Delivered` and the row stops being delivery work.
///
/// It is **not** enough for a skip. A skipped stop is a failed attempt, not
/// a closed consignment: the server rightly leaves it `Out_for_Delivery`,
/// so the row keeps coming back as live delivery work and only this set was
/// holding it off the list. Kill the app, and the set went with it — the
/// stop the rider wrote off an hour ago was back at the top of his queue,
/// while its record sat in Activity saying it was done. Two screens, two
/// answers, and the one he had to act on was the wrong one.
///
/// The completed store already holds exactly the right rows: what this
/// device closed, today, under this rider's scope, pruned nightly. Reading
/// it back is the whole fix.
Future<void> _restoreClosedToday() async {
try {
final closed = await getCompletedBookings();
if (closed.isEmpty || !mounted) return;
setState(() {
for (final b in closed) {
_completedOrderKeys.add(_getOrderKey(b));
}
});
debugPrint('[MYPICKUPS] Restored ${closed.length} closed stop(s)');
} catch (e) {
debugPrint('[MYPICKUPS] Could not restore closed stops: $e');
}
}
Future<void> _restoreActivePickup() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedActiveId = prefs.getString('active_pickup_order_id');
if (savedActiveId != null && savedActiveId.isNotEmpty) {
_activePickupOrderId = savedActiveId;
debugPrint(
'[MYPICKUPS] Restored active pickup orderId: $savedActiveId',
);
}
} catch (e) {
debugPrint('[MYPICKUPS] Error restoring active pickup: $e');
}
}
/// Restarts the poller at the speed the rider's current situation deserves.
///
/// Cheap to call: if the interval has not changed, the running stream is left
/// alone rather than torn down and rebuilt (which would reset the clock and,
/// called from a fetch handler, could starve the poll entirely).
void _syncPolling() {
if (!_pollForeground) {
_pollerSubscription?.cancel();
_pollerSubscription = null;
_currentPollInterval = null;
return;
}
final Duration interval = pollInterval(
hasLiveWork: _activePickups.isNotEmpty,
onDuty: _onDuty,
);
if (_pollerSubscription != null && _currentPollInterval == interval) return;
_pollerSubscription?.cancel();
_currentPollInterval = interval;
_pollerSubscription = Stream.periodic(interval, (_) {})
.asyncMap((_) async {
if (mounted && !_fetching) {
await _fetchPicked();
}
})
.listen(
(_) {},
onError: (error) {
debugPrint('[MYPICKUPS][STREAM ERROR] $error');
},
cancelOnError: false,
);
}
void _startPollingStream() => _syncPolling();
/// The screen stays awake only while a stop is actually live.
///
/// It used to be held for the whole time Bookings was open — so a rider with
/// the phone in his pocket and nothing running had a screen that would not
/// sleep, which is the fastest way to flatten a battery mid-shift. Worse, it
/// was held *here*, on the list, and not on the map screen where he is
/// actually navigating and genuinely needs it.
void _syncWakelock() {
final bool keepAwake = _activePickups.isNotEmpty;
if (keepAwake == _wakelockHeld) return;
_wakelockHeld = keepAwake;
if (keepAwake) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
final bool foreground = state == AppLifecycleState.resumed;
if (foreground == _pollForeground) return;
_pollForeground = foreground;
if (foreground) {
// One catch-up fetch immediately, so he never comes back to a queue that
// was true a minute ago, then the normal cadence.
_fetchPicked(force: true);
_syncPolling();
} else {
_syncPolling();
// Nothing is being watched, so nothing needs the screen.
if (_wakelockHeld) {
_wakelockHeld = false;
WakelockPlus.disable();
}
}
}
/// Duty state decides the idle poll rate. Read from the same pref the duty
/// toggle writes, so Bookings and Home agree without a second source.
Future<void> _loadDutyState() async {
try {
final prefs = await SharedPreferences.getInstance();
final int onduty = prefs.getInt('onduty') ?? 1;
if (!mounted) return;
final bool next = onduty != 0;
if (next != _onDuty) {
setState(() => _onDuty = next);
_syncPolling();
}
} catch (_) {
// Duty is unreadable — assume on duty, which polls more often rather
// than less. Being wrong in the direction of freshness is the safe way
// to be wrong about whether a rider is working.
}
}
Future<void> _initializeLocation() async {
try {
// 1. Try Last Known Position (Instant & Preferred)
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
_currentLocation = lastPos;
});
// If we have a cached location, don't block waiting for fresh GPS
// We can let the background stream update it later
return;
}
} catch (_) {}
// 2. Try Current Position (Optimized)
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return;
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever ||
permission == LocationPermission.denied) {
return;
}
// Single attempt with balanced accuracy/timeout
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 3),
),
);
if (mounted) {
setState(() => _currentLocation = position);
}
} catch (_) {
// Fallback to low accuracy if high fails
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 2),
),
);
if (mounted) {
setState(() => _currentLocation = position);
}
} catch (_) {}
}
} catch (e) {
debugPrint('[MYPICKUPS] Error initializing location: $e');
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
BottomPage.currentTab.removeListener(_onTabChanged);
_pollerSubscription?.cancel();
for (final timer in _pickupTimers.values) {
timer.cancel();
}
_pickupTimers.clear();
_pickupBasePayload.clear();
// Persist current live pickup state for other screens
SharedPreferences.getInstance()
.then((p) => p.setBool('has_live_pickup', _picked.isNotEmpty))
// ignore: body_might_complete_normally_catch_error
.catchError((_) {});
if (_wakelockHeld) WakelockPlus.disable();
super.dispose();
}
bool _shallowMapEquals(Map<String, dynamic> a, Map<String, dynamic> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (final entry in a.entries) {
if (b[entry.key] != entry.value) {
return false;
}
}
return true;
}
// Helper to check if two lists of bookings are effectively equal
// This prevents unnecessary rebuilds when polling
bool _areOrdersEqual(
List<Map<String, dynamic>> oldList,
List<Map<String, dynamic>> newList,
) {
if (oldList.length != newList.length) return false;
for (int i = 0; i < oldList.length; i++) {
final oldItem = oldList[i];
final newItem = newList[i];
// Compare critical fields that affect UI or logic
if (oldItem['orderid'] != newItem['orderid']) return false;
if (oldItem['orderstatus'] != newItem['orderstatus']) return false;
if (oldItem['step'] != newItem['step']) return false;
// Compare location data (important for map updates)
if (oldItem['riderslat'] != newItem['riderslat']) return false;
if (oldItem['riderslon'] != newItem['riderslon']) return false;
if (oldItem['pickuplat'] != newItem['pickuplat']) return false;
if (oldItem['pickuplong'] != newItem['pickuplong']) return false;
// Compare notes/instructions if they might change
if (oldItem['notes'] != newItem['notes']) return false;
}
return true;
}
// Manually mark skipped to trigger immediate refresh
void markOrderAsSkipped(Map<String, dynamic> Booking, String reason) {
if (!mounted) return;
final key = _getOrderKey(Booking);
// Record the skip locally so it sticks even if the backend hasn't recorded
// it yet (e.g. mock/offline data). _fetchPicked re-applies this cache on
// every poll so the status can't be reverted.
_skippedOrdersCache[key] = reason;
_skippedOrderTimestamps[key] = DateTime.now().millisecondsSinceEpoch;
// Immediately reflect the skipped status so the card updates and the next
// stop unlocks without waiting on the (possibly failing) API round-trip.
setState(() {
for (final o in _picked) {
if (_getOrderKey(o) == key) {
o['orderstatus'] = 'skipped';
}
}
});
debugPrint('[MYPICKUPS] Marked skipped locally -> $key ("$reason")');
_fetchPicked(force: true);
}
// ✅ Check if there's an active pickup (blocks all actions)
bool _hasActivePickup() {
return _activePickups.isNotEmpty;
}
/// The live stops this screen is allowed to open — see
/// [MilkRun.worksOnOwnScreen].
///
/// Not the same question as [_hasActivePickup]. That one is about the rider
/// (is a job running at all — it drives the poll rate, the wakelock and the
/// exit warning) and stays as it is. This one is about *this screen*: whether
/// the running job is one it can take him into.
List<Map<String, dynamic>> get _liveOnThisScreen => [
for (final p in _activePickups)
if (MilkRun.worksOnOwnScreen(p, collectedIds: _collectedIds)) p,
];
// ══════════════════════════════════════════════════════════════════════
// START DELIVERY — the load leaves the counter
//
// ── Why this is a press and not a side effect of the pickup ──
//
// The release used to ride along with `pickup-complete`, back on Home: the
// rider slid to confirm five bags at a kitchen and the same gesture posted
// all five consignments `Out_for_Delivery`. The hub's board then showed five
// *active* deliveries for food still on the shelf in front of him, and there
// was no moment left in the day that meant "I have set off" — picked and
// out-for-delivery had been collapsed into one claim.
//
// They are two different claims about where the load is, made minutes or an
// hour apart, and only the rider can make the second one. So it is his press,
// on the tab that holds the load.
//
// ── Why one bar for the whole load ──
//
// `POST /miler/deliveries/start` takes a batch because the gesture is one
// press after the last counter. Releasing per card would be five presses for
// one act and would leave the round half-open — some rows deliverable, some
// refused at the door by a server the rider cannot argue with.
// ══════════════════════════════════════════════════════════════════════
/// Orders in the rider's hands that he has not yet declared himself riding.
///
/// The bar's subject, and the reason it appears at all: an empty answer means
/// the whole load is already on the road and there is nothing left to press.
///
/// Empty on every line but the milk run, and deliberately: a logistics parcel
/// is collected *for the hub* and delivered by somebody else, so its rider
/// has no round to start and must never be shown a control that says he has.
/// [MilkRun.navigatesToCustomer] asks the same question one stop at a time.
List<Map<String, dynamic>> get _unreleased => !_isMilkRun
? const []
: [
for (final s in _visibleStops)
// The same three exclusions the queue itself applies, so the bar
// can never count a row the rider cannot see: work he has already
// closed, work he has put down, and work already on the road.
if (!stopStatusOf(s).isWorkComplete &&
!stopStatusOf(s).isCancelled &&
!stopStatusOf(s).isSkipped &&
!_completedOrderKeys.contains(_getOrderKey(s)) &&
_needsRelease(s))
s,
];
/// Whether this stop is still waiting on the rider's **Start delivery**.
///
/// ── Read from the consignment, not from a flag in this app ──
///
/// Two backend behaviours are live at once and the app must not care which
/// it is talking to. `pickup-complete` either leaves a hyperlocal
/// consignment `Out_for_Delivery` (as it does today) or stops it at
/// `Collected_By_Miler` and waits for `start-delivery` — a server-side
/// setting, not something this app is told about.
///
/// So the row is asked. `consignmentstatus` ships on every
/// `GET /miler/bookings` row, and it answers the question exactly: needing
/// release means needing release, and already out for delivery means the bar
/// has nothing to offer. **No feature flag is mirrored here**, because a
/// mirrored flag is a second source of truth that can be wrong.
///
/// The local sets are the fallback for a row that carries no consignment
/// status yet — an older deployment, or a stop collected seconds ago that
/// the queue has not caught up with.
bool _needsRelease(Map<String, dynamic> stop) {
final state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state != ConsignmentState.unknown) {
return state.needsRelease;
}
final id = MilkRun.idOf(stop);
return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id);
}
/// True while the round is being marked started, so the bar cannot be
/// double-pressed.
bool _starting = false;
/// Sets off: releases each collected consignment for delivery, server first.
///
/// ── This is a real write now ──
///
/// It has been three different things, and the history matters because two
/// of them were wrong in opposite directions.
///
/// It began as `POST /miler/deliveries/start` — a route that is not on the
/// contract and not in the backend's table. It 404'd every time, so the bar
/// could never succeed. It was then cut back to a purely local record, on
/// the evidence that `pickup-complete` was itself pushing hyperlocal work
/// to `Out_for_Delivery`: the load really was released before it reached
/// this tab, and the press only recorded what the rider had done with it.
///
/// The backend closed that gap on 21 Aug 2026. `pickup-complete` now stops
/// at `Collected_By_Miler` — collected, in the rider's hands, not yet on the
/// road — and `POST /miler/consignments/:id/start-delivery` makes the
/// release. So the press has a server meaning again, and the hub stops
/// seeing "out for delivery" for food still on the kitchen counter.
///
/// **The local set is written only for what the server released.** A stop
/// whose consignment could not be reached or was refused keeps its PICKED
/// word and stays on the bar, because the alternative — showing ACTIVE for
/// a consignment the hub still calls collected — is the fiction that made
/// `deliver` fail at the door with nothing on screen to explain why.
///
/// Returns the number of orders actually released.
Future<int> startRound(List<Map<String, dynamic>> stops) async {
if (_starting || stops.isEmpty) return 0;
setState(() => _starting = true);
try {
final released = <String>[];
var unreachable = 0;
for (final stop in stops) {
final orderId = MilkRun.idOf(stop);
if (orderId.isEmpty) continue;
if (await releaseForDelivery(stop)) {
released.add(orderId);
} else {
unreachable++;
}
}
_unreleasedFailures = unreachable;
if (released.isEmpty) return 0;
await addOutForDeliveryOrderIds(released);
await stampOrderEvents(released, OrderEvent.outForDelivery);
if (mounted) {
setState(
() => _outForDeliveryIds = {..._outForDeliveryIds, ...released},
);
}
return released.length;
} catch (e) {
debugPrint('[MILKRUN] could not record the round starting: $e');
return 0;
} finally {
if (mounted) setState(() => _starting = false);
}
}
/// How many stops the last [startRound] could not release. Read once by
/// [_startWholeRound] to word the message honestly.
int _unreleasedFailures = 0;
/// Scroll extent to reserve under the queue for whatever floats over it.
///
/// Reads the same two conditions the slot itself reads, so the floor and the
/// surface can never disagree about which one is on screen.
double _bottomReservation(BuildContext context) => bottomReservationFor(
inset: BottomPage.bottomInset(context),
live: _liveOnThisScreen.isNotEmpty,
unreleased: _unreleased.isNotEmpty,
idle: 8.h,
);
Future<void> _startWholeRound() async {
final stops = _unreleased;
final n = await startRound(stops);
if (!mounted) return;
final held = _unreleasedFailures;
if (n == 0) {
AppFeedback.errorGlobal(
held == 1
? 'Could not start that delivery. Check your connection and try '
'again.'
: 'Could not start the round. Check your connection and try again.',
);
return;
}
AppFeedback.successGlobal(
held > 0
? '$n started · $held could not be started'
: (n == 1 ? 'Delivery started' : '$n deliveries started'),
);
}
/// Marks a stop as finished (picked up or cancelled) in local state right
/// away, so the UI unblocks without waiting for the backend/poll to catch up:
/// it leaves the active banner, drops out of the top list, and no longer
/// blocks the next stop.
void markPickupFinished(
Map<String, dynamic> order, {
bool cancelled = false,
/// The exact word to file this stop under, when "cancelled" would be a
/// lie. A skipped delivery is the case: it is not a completion and not a
/// cancellation, and Activity has a slice for it.
String? terminalStatus,
}) {
final key = _getOrderKey(order);
final orderId = (order['orderid'] ?? '').toString();
_completedOrderKeys.add(key);
_stopPickupPosting(orderId);
// ── The bonus, before it is overwritten by the next stop ──
//
// `PickupsController` computes it when the stop closes and holds it in one
// observable that the *next* completion replaces. It was read once, to
// animate a number on the done screen, and then lost — so Activity could
// never answer "what was that one worth". Carried onto the record here,
// which is the only moment it is still this stop's.
try {
final earned = Get.isRegistered<PickupsController>()
? Get.find<PickupsController>().lastBonusPoints.value
: 0;
if (earned > 0) order['bonuspts'] = earned;
} catch (_) {
// No controller registered — a stop closed from a screen that never
// created one. The row simply has no bonus on it.
}
// Write it down. This is the only moment the app knows the stop is
// finished — the status call is optimistic and the queue endpoints will not
// report it for a poll or two, if at all on demo data — so without this the
// Activity tab has nothing to show for a shift's work. See
// [addCompletedBookings].
addCompletedBookings(
[order],
cancelled: cancelled,
terminalStatus: terminalStatus,
)
.then(
(_) => debugPrint(
'[ACTIVITY] recorded $orderId as '
'${terminalStatus ?? (cancelled ? 'cancelled' : 'picked')}',
),
)
// Fire-and-forget, but not silently: if this write ever fails the
// Activity tab is empty and there is nothing on screen to say why.
.catchError(
(Object e) => debugPrint('[ACTIVITY] failed to record $orderId: $e'),
);
// Drop it from the local accepted store so a completed/cancelled stop stops
// counting as an open booking — otherwise it resurfaces on the Bookings tab
// (and as a "next stop") after a rebuild, since the backend may still report
// it in a post-accept state.
if (orderId.isNotEmpty) removeAcceptedBookings([orderId]);
// A collected order is no longer being carried once it is through a door.
// Left in the set it would keep the card off Home for the rest of the
// install, and tomorrow's run would inherit it.
if (orderId.isNotEmpty) removeCollectedOrderIds([orderId]);
// …and it is no longer on the round. Same reason: an id left in the
// released set keeps a delivered stop on this tab for the life of the
// install.
if (orderId.isNotEmpty) removeOutForDeliveryOrderIds([orderId]);
// ── "Delivery 3 of 10 done" ──
//
// A meal run is one motion repeated: door, hand over, next. What a rider
// cannot see from inside it is how much is left, and a queue that silently
// gets shorter answers that only if he stops to count. Naming the position
// is the difference between ten identical stops and visible progress.
//
// Counted before the setState below removes it, so the number he reads is
// the one he just finished, not the one after.
if (ServiceProfile.active.handsOffAtCollection && !cancelled) {
final total = _picked.length + _completedToday.length;
final done = _completedToday.length + 1;
AppFeedback.successGlobal(
total > 0 ? 'Delivery $done of $total done' : 'Delivery done',
);
}
if (mounted) {
setState(() {
_activePickups.removeWhere((o) => _getOrderKey(o) == key);
_picked.removeWhere((o) => _getOrderKey(o) == key);
if (_activePickupOrderId == orderId) _activePickupOrderId = null;
// Straight onto the rail, ahead of the store write landing and the next
// poll reading it back. Newest first, matching the order
// [getCompletedBookings] returns — the rail reverses it. Without this
// the node the rider just closed stays grey for a poll cycle, which is
// the exact moment he looks at it.
if (!_completedToday.any((o) => _getOrderKey(o) == key)) {
final stamped = Map<String, dynamic>.from(order);
// The same terminal verb the completed store writes — a milk-run
// stop ends `delivered`, a logistics one `picked`. The two must
// agree or the rail and the store disagree about the same stop.
stamped['orderstatus'] = cancelled
? 'cancelled'
: (ServiceProfile.active.deliversToCustomer
? 'delivered'
: 'picked');
_completedToday.insert(0, stamped);
}
});
}
// Clear the persisted active-pickup markers so nothing stays "blocked"
// across a rebuild/restart.
SharedPreferences.getInstance().then((prefs) async {
if (_activePickups.isEmpty) {
await prefs.setBool('has_live_pickup', false);
await prefs.remove('active_pickup_order_id');
}
});
}
/// Bookings still open for pickup (excludes finished/cancelled), used to seed
/// the "next stops" list on the completion screen.
List<Map<String, dynamic>> remainingPickups() {
return _picked.where((o) {
return !stopStatusOf(o).isFinishedPickup &&
!_completedOrderKeys.contains(_getOrderKey(o));
}).toList();
}
// Previously showed a blocking dialog; now we just disable conflicting buttons in the UI.
void _showActivePickupBlockMessage() {}
/// A rider must be online (on duty) to start/continue a pickup or delivery.
/// Returns true if online; otherwise shows a prompt and returns false.
Future<bool> ensureOnlineForAction() async {
// Single source of truth — same resolution the Home screen uses.
final bool online = await DutyController.to.load();
if (!online && mounted) {
AppFeedback.info(
context,
"You're offline. Go online from Home to start pickups.",
);
}
return online;
}
Future<void> startPickup(Map<String, dynamic> item) async {
if (!mounted) return;
// ── A collection is not worked from this screen ──
//
// On a kitchen line the rungs up to *picked up* belong to Home, in bulk:
// the rider is handed a stack of bags at one counter and slides once for
// all of them. Opening this stop's own map/arrive/confirm screen for a
// collection gave him a second, contradictory way to work the same rung —
// and it posted the booking ACTIVE from a tab that is supposed to be his
// load, so a stop he had not collected started reporting as live.
//
// Refused before the status write below, not after it. See
// [MilkRun.worksOnOwnScreen].
if (!MilkRun.worksOnOwnScreen(item, collectedIds: _collectedIds)) {
AppFeedback.info(
context,
'Collections are confirmed on Home — select the orders at the '
'kitchen and slide to update.',
);
return;
}
// Online-gated: only an on-duty rider can start a pickup/delivery.
if (!await ensureOnlineForAction()) return;
if (!mounted) return;
// ✅ BLOCK: Check if there's already an active pickup (and this isn't it)
final currentOrderId = (item['orderid'] ?? '').toString();
final activeOrderIds = _activePickups
.map((d) => (d['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// Allow if this IS the active pickup, block if there's a different active pickup
if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) {
// Just ignore taps on other cards when a different active pickup exists.
return;
}
// Verify status is updated to ACTIVE before navigating
final dc = Get.put(PickupsController());
final dId = int.tryParse((item['pickupid'] ?? 0).toString()) ?? 0;
final ohId = int.tryParse((item['orderheaderid'] ?? 0).toString()) ?? 0;
if (dId > 0 && ohId > 0) {
// We don't block navigation on failure, but we try to update
// This ensures 'starttime' is generated and saved
await dc.updateActiveStatus(
pickupId: dId,
orderHeaderId: ohId,
orderId: (item['orderid'] ?? '').toString(),
);
}
// ── Opening a stop starts its leg, if the bar has not already ──
//
// START DELIVERY is the normal way in and releases the whole load at once.
// This is the other way in: a rider who taps a card without pressing the
// bar is just as much on his way, and the stop has to be released before he
// gets there — `deliver` refuses any consignment that is not
// `Out_for_Delivery`, so opening one unreleased would walk him to a door
// and fail him at it.
//
// The same call the bar makes, for one order. [startRound] writes the local
// marker only after the server agrees, so a failure here leaves the row
// reading PICKED rather than lying about a round the hub has not started.
if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) &&
!_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) {
await startRound([item]);
if (!mounted) return;
}
await openScreen(
context,
_PickupMapScreen(pickup: item, parentState: this),
swipeToGoBack: false,
);
}
Future<void> resumePickup(Map<String, dynamic> item) async {
// ✅ BLOCK: Check if there's already an active pickup (and this isn't it)
final currentOrderId = (item['orderid'] ?? '').toString();
final activeOrderIds = _activePickups
.map((d) => (d['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// Allow if this IS the active pickup, block if there's a different active pickup
if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) {
// Ignore resume taps when some other pickup is active.
return;
}
// For now, resume behaves same as start (navigates to nav screen)
// You can add specific resume logic here if needed (e.g. un-skip)
await startPickup(item);
}
Future<void> _fetchPicked({bool force = false}) async {
if (_fetching) {
if (force) {
_fetching = false;
} else {
return;
}
}
_fetching = true;
// The bag each order is in, re-read with the list it labels: a pickup that
// happened on Home a moment ago must show its bags here without the rider
// pulling to refresh.
unawaited(_loadBagLabels());
try {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
// ✅ CRITICAL: Fetch from v3 API (picked bookings) AND v2 API (all statuses) IN PARALLEL
// Define V1 fetcher function (current/in-progress bookings)
Future<List<dynamic>> fetchV1() async {
try {
return await _provider.getCurrentPickups();
} catch (e) {
debugPrint('[MYPICKUPS] Error fetching from v1 API: $e');
}
return [];
}
// Execute in parallel with individual error handling
Future<List<dynamic>> fetchV3() async {
try {
return await _provider.getPickupQueuesPicked();
} catch (e) {
debugPrint('[MYPICKUPS] V3 API error: $e');
// Return empty and let the single demo fallback below decide — two
// competing mock paths is how the old one ended up unreachable.
return <dynamic>[];
}
}
final results = await Future.wait([fetchV3(), fetchV1()]);
final itemsV3 = results[0] as List<dynamic>;
final itemsV1 = results[1] as List<dynamic>;
// Merge v3 bookings with v1 bookings (current/in-progress)
final Map<String, Map<String, dynamic>> mergedOrders = {};
// First add v3 bookings (picked/pickuped)
for (final Booking in itemsV3.whereType<Map<String, dynamic>>()) {
final key = _getOrderKey(Booking);
mergedOrders[key] = Booking;
}
// Then add/update with v1 bookings - V1 STATUS TAKES PRECEDENCE
for (final Booking in itemsV1.whereType<Map<String, dynamic>>()) {
final key = _getOrderKey(Booking);
final existing = mergedOrders[key];
if (existing == null) {
mergedOrders[key] = Booking;
} else {
// If it exists in V3 (picked) but V1 says something else, trust V1.
// Especially for 'skipped', 'Picked up', 'cancelled'.
mergedOrders[key] = {
...existing,
...Booking, // Overwrite with V1 data
};
}
}
final items = mergedOrders.values.toList();
debugPrint('[MYPICKUPS] Merged items count: ${items.length}');
// Today's finished stops, for the rail. Read on every fetch rather than
// held from the last completion, so the rail is right after a restart
// mid-shift too.
_completedToday = await getCompletedBookings();
// Merge in bookings accepted from Home (offline/mock accept flow) so the
// Bookings tab count grows as the rider accepts.
final acceptedBookings = await getAcceptedBookings();
if (acceptedBookings.isNotEmpty) {
final existingKeys = items.map(_getOrderKey).toSet();
int added = 0;
for (final Booking in acceptedBookings) {
if (existingKeys.add(_getOrderKey(Booking))) {
items.add(Booking);
added++;
}
}
debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home');
}
// ── The day's shape, before any of this screen's filtering ──
//
// Everything today: what is assigned, what he took, what he finished.
// This is the only point in the fetch where all three are in one list,
// and it is what makes "Trip 2" mean the same thing here as it does on
// Home. See [_tripNumberByOrderId].
_tripNumberByOrderId = _numberTrips([...items, ..._completedToday]);
// Admin-assigned bookings the rider hasn't accepted yet belong on the
// Home tab, not here. Keep only bookings the rider has actually taken on:
// those locally recorded as accepted, or already in a post-accept backend
// state. Anything still pending/assigned is dropped from Bookings.
final acceptedIds = await getAcceptedOrderIds();
// ── This screen does not decide what it owns ──
//
// [WorkBoundary] does, for both work screens at once, out of the shared
// day. That is the whole point of it: the gate used to be written here as
// a `where` over the raw statuses, Home decided the same thing its own
// way, and the two could — and did — both claim an accepted booking.
//
// Where the boundary sits still follows the line, exactly as it did:
// logistics hands over at acceptance, a kitchen line at pickup-complete.
// What changed is that the rule is declared once, on
// [ServiceProfile.handoffAt], instead of being re-derived on each screen.
//
// ── What this gate is deliberately NOT ──
//
// It is not the *released* set. Being on this tab and being deliverable
// are two different things: the round does not open until the rider
// presses START DELIVERY. That gate lives on the rows and on the bar at
// the foot of this screen — see [_deliveryLocked] and [MilkRun] — rather
// than on the fetch, because hiding the orders until release left them on
// no tab at all: dropped from Home as collected, withheld from here as
// unreleased.
final Set<String> collectedIds = await getCollectedOrderIds();
// ── Held on the state whatever the line ──
//
// Every "which leg is this stop on?" question on this screen and in the
// sheets it opens reads this set — see [MilkRun.workingKind] and the
// sheet's `_carrying`. It used to be assigned only inside the milk-run
// branch below, so on any other line those questions were asked against
// an empty set and had to fall back to guessing from the raw status.
if (mounted && !setEquals(_collectedIds, collectedIds)) {
setState(() => _collectedIds = collectedIds);
}
// The whole day and the sets the round is gated on. Held on the state so
// the bar at the foot of this screen and the lock on each row read the
// same facts the fetch just established.
if (_isMilkRun) {
final outForDeliveryIds = await getOutForDeliveryOrderIds();
final notLoadedIds = await getNotLoadedOrderIds();
final rejectedIds = await getRejectedOrderIds();
if (mounted) {
setState(() {
_allDayStops = items.whereType<Map<String, dynamic>>().toList();
_acceptedIds = acceptedIds;
// `_collectedIds` is set above, for every line.
_outForDeliveryIds = outForDeliveryIds;
_notLoadedIds = notLoadedIds;
_rejectedIds = rejectedIds;
});
}
}
// One call, one rule — see [WorkBoundary.deliveryQueue]. The second
// `where` that used to sit here, re-admitting rows by raw status, is gone
// with it: it was the hole an accepted-but-uncollected booking came
// through, and a second expression of a fact that now has one.
var dedupedList = WorkBoundary.deliveryQueue(
items.whereType<Map<String, dynamic>>(),
collectedIds: collectedIds,
acceptedIds: acceptedIds,
);
// Drop stops the rider just finished. The backend may still return them
// as 'active'/'picked' for a poll cycle or two; without this they'd
// reappear on the list and re-block the next stop.
if (_completedOrderKeys.isNotEmpty) {
dedupedList.removeWhere(
(o) => _completedOrderKeys.contains(_getOrderKey(o)),
);
}
// Preserve step numbers for existing bookings
for (final Booking in dedupedList) {
final orderKey = _getOrderKey(Booking);
final currentStep = _getStepNumber(Booking);
if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) {
_preservedStepNumbers[orderKey] = currentStep;
}
}
// Sort the list
final sortedList = _sortOrders(dedupedList);
// Definitive per-order status dump — tells us exactly why an order shows
// (or doesn't): e.g. "1234:picked" means it's completed, not assignable.
debugPrint(
'[MYPICKUPS] fetched statuses='
'${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}',
);
// Re-apply locally-skipped orders so a skip survives polling even when the
// backend hasn't recorded it yet (e.g. mock/offline data). Without this,
// the 3s poll would overwrite the skip with the raw API status. Applied
// before the filter below, because the filter now acts on it.
if (_skippedOrdersCache.isNotEmpty) {
for (final o in sortedList) {
if (_skippedOrdersCache.containsKey(_getOrderKey(o))) {
o['orderstatus'] = 'skipped';
}
}
}
// Build the top list = the stops he is actually working.
// - ACTIVE stops go to the banner, not the list.
// - FINISHED stops (picked / cancelled) must be excluded entirely, not
// merely hidden per-card. Otherwise the header/route-strip count
// (_picked.length) included completed stops the cards then rendered as
// SizedBox.shrink — a "2 stops" ghost with no visible cards. They are
// on the Activity tab.
// - SKIPPED stops **stay**, under their own heading.
//
// They used to leave, and the reason was sound at the time: left in
// the list they sat among the live stops looking identical to them,
// and the rider had to remember which of his eight cards were real. So
// they were sent to Home to wait with the undecided work.
//
// That reason is gone. The queue is no longer one card repeated — a
// skipped stop now renders as its own density (see [QueueDensity]):
// under a SKIPPED heading, carrying the reason in warning colour, with
// an outlined Resume and no primary action. It cannot be mistaken for
// live work at a glance, which was the entire objection.
//
// Keeping it here is also where the rider looks for it. He skipped it
// twenty minutes ago on this screen, in this queue; sending it to a
// different tab to be resumed is a hunt for something he never moved.
final listForTop = sortedList.where((o) {
final st = stopStatusOf(o);
return !st.isActive && !st.isFinishedPickup;
}).toList();
debugPrint(
'[MYPICKUPS] Final top list: ${listForTop.length} '
'(skipped held on Home: ${_skippedOrdersCache.length})',
);
// Check if data has actually changed before rebuilding
// This avoids unnecessary setState calls during polling
// Debug-only trace of every stage this list passes through. Three
// separate causes have made this tab look empty — a gate on the raw API
// count, a missing empty state on an unfilled trip tab, and stops
// filtered out as still-pending — and every one of them looked identical
// on screen. The counts are now shown on the empty state itself, so the
// failing stage is visible instead of guessed at.
_fetchDiag =
// Which line the app resolved the rider onto. Three of the reports
// that landed here as "the tab is wrong" were really "the app thinks
// I am on the other line", and nothing on screen said so.
'line=${ServiceProfile.active.line.name} '
'api=${items.length} '
'accepted=${acceptedIds.length} '
'workable=${dedupedList.length} '
'sorted=${sortedList.length} '
'list=${listForTop.length} '
'active=${sortedList.where((o) => stopStatusOf(o).isActive).length}';
debugPrint('[MYPICKUPS][DIAG] $_fetchDiag');
if (_fetchFailed && mounted) setState(() => _fetchFailed = false);
final bool hasChanged = !_areOrdersEqual(_picked, listForTop);
if (hasChanged) {
if (mounted) {
setState(() {
_picked = listForTop;
});
debugPrint(
'[MYPICKUPS] 🔄 UI Updated: ${listForTop.length} bookings',
);
}
} else {
// No changes
}
// Get all Booking IDs from the current list
final currentOrderIds = sortedList
.map((o) => (o['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// ✅ Find all ACTIVE pickup and ensure timers are running
final activeOrders = sortedList
.where((o) => stopStatusOf(o).isActive)
.toList();
debugPrint('[MYPICKUPS] Found ${activeOrders.length} active pickup');
// ✅ CRITICAL: Only set has_live_pickup to true if there are ACTIVE bookings (status = "active")
// Don't set it to true for "picked" or other statuses - only for "active"
try {
final hasActiveOrders = activeOrders.isNotEmpty;
await prefs.setBool('has_live_pickup', hasActiveOrders);
debugPrint(
'[MYPICKUPS] Set has_live_pickup: $hasActiveOrders (${activeOrders.length} active bookings)',
);
// Clear active_pickup_order_id if no active pickup
if (!hasActiveOrders) {
final activeOrderId = prefs.getString('active_pickup_order_id');
if (activeOrderId != null && activeOrderId.isNotEmpty) {
debugPrint(
'[MYPICKUPS] Clearing stale active_pickup_order_id: $activeOrderId (no active pickup)',
);
await prefs.remove('active_pickup_order_id');
}
}
} catch (_) {}
// Update active pickup list for banner display
if (mounted) {
setState(() {
_activePickups = activeOrders;
});
// Whether a stop is live governs both how fast we poll and whether the
// screen is held awake, so they are re-derived wherever it changes.
_syncPolling();
_syncWakelock();
}
// Get set of active Booking IDs
final activeOrderIds = activeOrders
.map((o) => (o['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// ✅ CRITICAL: Start timers ONLY for active pickup that don't have one yet
// This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch
for (final Booking in activeOrders) {
final orderId = (Booking['orderid'] ?? '').toString();
if (orderId.isEmpty) continue;
// ✅ ONLY start timer if it doesn't already exist
// This prevents restarting timers on every _fetchPicked() call (which happens frequently)
if (!_pickupTimers.containsKey(orderId)) {
debugPrint(
'[MYPICKUPS] Active pickup: $orderId - Starting pickup log posting (timer not found)',
);
// ✅ RE-ENABLED: Pickup logs now posted from pickup page with robust validation
await _startPickupPosting(Booking);
} else {
debugPrint(
'[MYPICKUPS] Active pickup: $orderId - Timer already running, skipping restart',
);
}
_activePickupOrderId = orderId; // Track the active pickup
// Persist active pickup ID so it survives app restarts
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('active_pickup_order_id', orderId);
} catch (e) {
debugPrint('[MYPICKUPS] Error saving active pickup ID: $e');
}
}
// ✅ Stop timers ONLY for pickup that are no longer active or in the list
final timersToStop = _pickupTimers.keys
.where(
(id) =>
!activeOrderIds.contains(id) || !currentOrderIds.contains(id),
)
.toList();
for (final id in timersToStop) {
debugPrint(
'[MYPICKUPS] Stopping timer for orderId: $id (no longer active)',
);
_stopPickupPosting(id);
}
debugPrint('[MYPICKUPS] Active timers: ${_pickupTimers.keys.toList()}');
debugPrint('[MYPICKUPS] Active pickup orderId: $_activePickupOrderId');
} catch (e) {
debugPrint('[MYPICKUPS] Error fetching picked bookings: $e');
if (mounted) setState(() => _fetchFailed = true);
} finally {
_fetching = false;
if (!_firstLoadDone && mounted) {
setState(() => _firstLoadDone = true);
}
}
}
// ignore: unused_element
Map<String, dynamic> _createBasePayload(Map<String, dynamic> it) {
return <String, dynamic>{
'logid': 0,
'tenantid': it['tenantid'] ?? 0,
'partnerid': it['partnerid'] ?? 0,
'locationid': it['locationid'] ?? 0,
'orderheaderid': it['orderheaderid'] ?? 0,
'pickupid': it['pickupid'] ?? 0,
'userid': it['userid'] ?? 0,
'orderid': (it['orderid'] ?? '').toString(),
'orderstatus': 'active',
};
}
// Stop posting logs for a specific pickup
void _stopPickupPosting(String orderId) {
final timer = _pickupTimers.remove(orderId);
if (timer != null) {
timer.cancel();
debugPrint('[PICKUPLOG] Stopped posting logs for orderId: $orderId');
} else {
debugPrint('[PICKUPLOG] No timer to stop for orderId: $orderId');
}
}
Future<(String lat, String lng)?> _getValidCoordinates({
int retryCount = 0,
}) async {
const maxRetries = 3;
try {
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint(
'[PICKUPS][COORDS] Location service disabled, trying last known position',
);
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null &&
lastPos.latitude != 0 &&
lastPos.longitude != 0) {
final lat = lastPos.latitude.toString();
final lng = lastPos.longitude.toString();
debugPrint(
'[PICKUPS][COORDS] ✅ Using last known position: $lat, $lng',
);
return (lat, lng);
}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever ||
permission == LocationPermission.denied) {
debugPrint(
'[PICKUPS][COORDS] Permission denied, trying last known position',
);
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null &&
lastPos.latitude != 0 &&
lastPos.longitude != 0) {
final lat = lastPos.latitude.toString();
final lng = lastPos.longitude.toString();
debugPrint(
'[PICKUPS][COORDS] ✅ Using last known position: $lat, $lng',
);
return (lat, lng);
}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
Position? position;
try {
// Try to get current position with higher accuracy
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy:
LocationAccuracy.high, // Changed to high for better accuracy
timeLimit: Duration(seconds: 8), // Increased timeout
),
).timeout(const Duration(seconds: 8));
} catch (e) {
debugPrint(
'[PICKUPS][COORDS] Timeout getting current position: $e, trying last known',
);
position = await Geolocator.getLastKnownPosition();
}
if (position != null &&
position.latitude != 0 &&
position.longitude != 0) {
final lat = position.latitude.toString();
final lng = position.longitude.toString();
// Validate coordinates are within valid GPS ranges
final latDouble = double.tryParse(lat) ?? 0.0;
final lngDouble = double.tryParse(lng) ?? 0.0;
if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('last_lat', lat);
await prefs.setString('last_lng', lng);
} catch (_) {}
debugPrint('[PICKUPS][COORDS] ✅ Got valid coordinates: $lat, $lng');
return (lat, lng);
} else {
debugPrint(
'[PICKUPS][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng',
);
}
}
// Fallback to SharedPreferences cached coordinates
try {
final prefs = await SharedPreferences.getInstance();
final lat = (prefs.getString('last_lat') ?? '').trim();
final lng = (prefs.getString('last_lng') ?? '').trim();
if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') {
final latDouble = double.tryParse(lat) ?? 0.0;
final lngDouble = double.tryParse(lng) ?? 0.0;
if (latDouble != 0 &&
lngDouble != 0 &&
latDouble.abs() <= 90 &&
lngDouble.abs() <= 180) {
debugPrint(
'[PICKUPS][COORDS] ✅ Using cached coordinates: $lat, $lng',
);
return (lat, lng);
}
}
} catch (_) {}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
debugPrint(
'[PICKUPS][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates',
);
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
debugPrint(
'[PICKUPS][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries',
);
return null;
} catch (e) {
debugPrint('[PICKUPS][COORDS] ❌ Error getting coordinates: $e');
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
}
Future<void> _startPickupPosting(Map<String, dynamic> Booking) async {
final orderId = (Booking['orderid'] ?? '').toString();
if (orderId.isEmpty) {
debugPrint('[PICKUPS][PICKUPLOG] ⚠️ Cannot start timer: empty orderId');
return;
}
// Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above)
if (_pickupTimers.containsKey(orderId)) {
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Timer already exists for $orderId, canceling old one',
);
_pickupTimers[orderId]?.cancel();
_pickupTimers.remove(orderId);
}
debugPrint(
'[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId',
);
debugPrint(
'[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId',
);
// REMOVED: Do not reset cumulative distance here.
// It is already reset in PickupsController.updateActiveStatus when the status actually changes.
// Resetting here causes data loss if the app is restarted while a pickup is in progress.
// START FOREGROUND SERVICE PROTECTION
// Ensure the RiderLogController knows we are active so the Foreground Service stays alive
// This protects THIS timer from being killed by the OS
try {
if (Get.isRegistered<RiderLogController>()) {
final riderLog = Get.find<RiderLogController>();
debugPrint(
'[PICKUPS][PICKUPLOG] 🛡️ Activating Foreground Service via RiderLogController...',
);
// Force 30s interval to match pickup logging
riderLog.startAutoCreateLoginLoop(seconds: 30);
}
} catch (e) {
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Could not start Foreground Service: $e',
);
}
final pickupId = (Booking['pickupid'] ?? 0).toString();
// Get starttime from SharedPreferences (saved when Booking became active via updateActiveStatus)
// If not found, use activetime from Booking data, or current time as fallback
String startTime = '';
try {
final prefs = await SharedPreferences.getInstance();
// Method 1: Get from SharedPreferences (saved when Booking became active)
startTime = prefs.getString('pickup_starttime_$pickupId') ?? '';
if (startTime.isNotEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from SharedPreferences: $startTime',
);
}
// Method 2: Fallback - try to get from Booking data (starttime field)
if (startTime.isEmpty) {
startTime = (Booking['starttime'] ?? Booking['startTime'] ?? '')
.toString();
if (startTime.isNotEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from Booking data: $startTime',
);
}
}
// Method 3: Fallback - try activetime from Booking data
if (startTime.isEmpty) {
final activetime = (Booking['activetime'] ?? Booking['activTime'] ?? '')
.toString();
if (activetime.isNotEmpty) {
startTime = activetime;
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from activetime: $startTime',
);
}
}
// Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called)
if (startTime.isEmpty) {
final now = DateTime.now();
startTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Using current time as starttime fallback: $startTime',
);
}
} catch (e) {
debugPrint('[PICKUPS][PICKUPLOG] ❌ Error getting starttime: $e');
// Set a fallback starttime even on error
final now = DateTime.now();
startTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
}
// CRITICAL: Ensure starttime is never empty
if (startTime.isEmpty) {
final now = DateTime.now();
startTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Final fallback: starttime was empty, using: $startTime',
);
}
debugPrint(
'[PICKUPS][PICKUPLOG] 📝 Final starttime for orderId $orderId: $startTime',
);
// Create base payload with starttime
final base = <String, dynamic>{
'logid': 0,
'tenantid': Booking['tenantid'] ?? 0,
'partnerid': Booking['partnerid'] ?? 0,
'locationid': Booking['locationid'] ?? 0,
'orderheaderid': Booking['orderheaderid'] ?? 0,
'pickupid': Booking['pickupid'] ?? 0,
'userid': Booking['userid'] ?? 0,
'orderid': orderId,
'orderstatus': 'active',
'starttime': startTime, // Include starttime in base payload
};
_pickupBasePayload[orderId] = base;
// Save to SharedPreferences for persistence
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'pickuplog_${orderId}_tenantid',
(base['tenantid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_partnerid',
(base['partnerid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_locationid',
(base['locationid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_orderheaderid',
(base['orderheaderid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_pickupid',
(base['pickupid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_userid',
(base['userid'] ?? 0).toString(),
);
await prefs.setString('pickuplog_${orderId}_orderid', orderId);
await prefs.setString('pickuplog_${orderId}_orderstatus', 'active');
await prefs.setString(
'pickuplog_${orderId}_starttime',
startTime,
); // Save starttime
} catch (e) {
debugPrint('[PICKUPS][PICKUPLOG] Error saving payload: $e');
}
// Post once immediately (don't await - let it run in background)
_postPickupLog(orderId, Booking);
debugPrint(
'[PICKUPS][PICKUPLOG] 📤 Posted initial log for orderId: $orderId',
);
// Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds
final timer = Timer.periodic(const Duration(seconds: 30), (t) {
debugPrint(
'[PICKUPS][PICKUPLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)',
);
_postPickupLog(orderId, Booking);
});
_pickupTimers[orderId] = timer;
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)',
);
}
// Reset cumulative distance when Booking becomes active
Future<void> _resetCumulativeDistance(String pickupId) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('pickup_tracking_${pickupId}_lastLat');
await prefs.remove('pickup_tracking_${pickupId}_lastLng');
await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm');
debugPrint(
'[PICKUPS] 🧹 Reset cumulative distance tracking for pickupId: $pickupId',
);
} catch (e) {
debugPrint('[PICKUPS] Error resetting cumulative distance: $e');
}
}
void _postPickupLog(String orderId, Map<String, dynamic> Booking) {
if (!mounted) {
debugPrint('[PICKUPS][PICKUPLOG][POST] Widget disposed, skipping');
return;
}
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}',
);
// Use Future.microtask to ensure the async operation runs independently
Future.microtask(() => _performPost(orderId));
}
Future<void> _performPost(String orderId) async {
try {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] 🔄 Starting _performPost for orderId: $orderId',
);
Map<String, dynamic>? base = _pickupBasePayload[orderId];
if (base == null) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] Base is null, loading from SharedPreferences',
);
try {
final prefs = await SharedPreferences.getInstance();
if (!mounted) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] Widget unmounted after prefs load',
);
return;
}
base = {
'logid': 0,
'tenantid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_tenantid') ?? '0',
) ??
0,
'partnerid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_partnerid') ?? '0',
) ??
0,
'locationid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_locationid') ?? '0',
) ??
0,
'orderheaderid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0',
) ??
0,
'pickupid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_pickupid') ?? '0',
) ??
0,
'userid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_userid') ?? '0',
) ??
0,
'orderid':
prefs.getString('pickuplog_${orderId}_orderid') ?? orderId,
'orderstatus':
prefs.getString('pickuplog_${orderId}_orderstatus') ?? 'active',
'starttime':
prefs.getString('pickuplog_${orderId}_starttime') ??
'', // Load starttime
};
debugPrint(
'[PICKUPS][PICKUPLOG][POST] Base loaded from prefs: $base',
);
} catch (e) {
debugPrint('[PICKUPS][PICKUPLOG][POST] Error loading base: $e');
return;
}
}
// At this point, base is guaranteed to be non-null (either from cache or created above)
final basePayload = base; // Flow analysis ensures base is non-null here
debugPrint('[PICKUPS][PICKUPLOG][POST] Getting coordinates...');
// CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates
final coords = await _getValidCoordinates().timeout(
const Duration(seconds: 10), // Increased timeout to allow retries
onTimeout: () {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ Coordinate timeout after retries',
);
return null;
},
);
if (!mounted) {
debugPrint('[PICKUPS][PICKUPLOG][POST] Widget unmounted after coords');
return;
}
// CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates
if (coords == null ||
coords.$1.isEmpty ||
coords.$2.isEmpty ||
coords.$1 == '0' ||
coords.$2 == '0') {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})',
);
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
);
return; // Skip this post - don't send invalid coordinates
}
// Validate coordinate ranges
final latDouble = double.tryParse(coords.$1) ?? 0.0;
final lngDouble = double.tryParse(coords.$2) ?? 0.0;
if (latDouble == 0 ||
lngDouble == 0 ||
latDouble.abs() > 90 ||
lngDouble.abs() > 180) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)',
);
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
);
return; // Skip this post - don't send invalid coordinates
}
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}',
);
// CRITICAL: Track cumulative distance traveled from active to Picked up/cancelled
// Calculate distance from last GPS location and accumulate
final pickupId = base['pickupid']?.toString() ?? '';
if (pickupId.isNotEmpty) {
try {
final prefs = await SharedPreferences.getInstance();
// Get last GPS location for this pickup
final lastLatStr =
prefs.getString('pickup_tracking_${pickupId}_lastLat') ?? '';
final lastLngStr =
prefs.getString('pickup_tracking_${pickupId}_lastLng') ?? '';
final currentCumulativeKm =
double.tryParse(
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ??
'0',
) ??
0.0;
final currentLat = double.tryParse(coords.$1) ?? 0.0;
final currentLng = double.tryParse(coords.$2) ?? 0.0;
if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) {
final lastLat = double.tryParse(lastLatStr) ?? 0.0;
final lastLng = double.tryParse(lastLngStr) ?? 0.0;
if (lastLat != 0 &&
lastLng != 0 &&
currentLat != 0 &&
currentLng != 0) {
// Calculate distance from last location to current location
final distanceMeters = Geolocator.distanceBetween(
lastLat,
lastLng,
currentLat,
currentLng,
);
// Only add if distance is significant (filter out GPS noise/jitter)
// Ignore movements less than 10 meters (likely GPS drift)
if (distanceMeters >= 10.0) {
final distanceKm = distanceMeters / 1000.0;
final newCumulativeKm = currentCumulativeKm + distanceKm;
// Save updated cumulative distance
await prefs.setString(
'pickup_tracking_${pickupId}_cumulativeKm',
newCumulativeKm.toStringAsFixed(4),
);
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] 📍 Distance segment: ${distanceKm.toStringAsFixed(4)} km (${distanceMeters.toStringAsFixed(0)}m)',
);
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] 📊 Cumulative distance: ${newCumulativeKm.toStringAsFixed(4)} km',
);
} else {
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] ⏭️ Skipped small movement: ${distanceMeters.toStringAsFixed(1)}m (GPS noise)',
);
}
}
}
// Save current location as last location for next calculation
await prefs.setString(
'pickup_tracking_${pickupId}_lastLat',
coords.$1,
);
await prefs.setString(
'pickup_tracking_${pickupId}_lastLng',
coords.$2,
);
} catch (e) {
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] ❌ Error tracking cumulative distance: $e',
);
}
}
final now = DateTime.now();
final logdate =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
// CRITICAL: Ensure starttime is always included in payload
final starttimeValue = basePayload['starttime']?.toString() ?? '';
if (starttimeValue.isEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback',
);
}
// CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point
final payload = {
...basePayload,
'logdate': logdate,
'latitude': coords.$1, // Guaranteed non-null and valid
'longitude': coords.$2, // Guaranteed non-null and valid
'starttime': starttimeValue.isNotEmpty
? starttimeValue
: '', // CRITICAL: Always include starttime
};
// Validate payload has all required fields
final requiredFields = [
'tenantid',
'partnerid',
'locationid',
'orderheaderid',
'pickupid',
'userid',
'orderid',
'orderstatus',
'starttime',
];
final missingFields = requiredFields
.where((field) => payload[field] == null || payload[field] == '')
.toList();
if (missingFields.isNotEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields',
);
}
debugPrint('[PICKUPS][PICKUPLOG][POST] 📦 Payload: $payload');
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ✅ starttime in payload: "${payload['starttime']}"',
);
await _pickupLogProvider
.createPickupLog(payload)
.timeout(
const Duration(seconds: 8),
onTimeout: () {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ API timeout for orderId: $orderId',
);
throw TimeoutException('API timeout', const Duration(seconds: 8));
},
);
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}',
);
} catch (e, stackTrace) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ ERROR for orderId: $orderId - $e',
);
debugPrint('[PICKUPS][PICKUPLOG][POST] Stack trace: $stackTrace');
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_picked.isNotEmpty) {
final confirm = await showAppDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
// Same rule as the app bar above: a meal rider has deliveries
// pending, not pickups. `jobNounPlural` is 'deliveries' on that
// line and 'bookings' on a parcel route.
title: Text('Pending ${ServiceProfile.active.workTabLabel}'),
content: Text(
'Are you sure you want to close? There are '
'${ServiceProfile.active.jobNounPlural} pending.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Yes'),
),
],
),
);
if (confirm == true && context.mounted) {
Navigator.pop(context);
}
} else {
Navigator.pop(context);
}
},
child: Scaffold(
// The same white as Home, Activity and Account. This was `surface`
// (#FCF9F8, a warm off-white); the difference is invisible under a full
// list and obvious the moment the list is short, so the tab appeared to
// change colour with the rider's workload.
// The brand shows through the sheet's two top corners — see
// [MilerSheet].
backgroundColor: ColorConstants.primary,
appBar: MilerAppBar(
// ── The tab and the page it opens must agree ──
//
// This was the literal 'My Bookings', while the nav tab underneath it
// already read the noun off the profile
// (`ServiceProfile.active.workTabLabel`, see `Bottom_page.dart`). So a
// meal rider tapped a tab called **Deliveries** and arrived on a page
// titled **My Bookings** — the one screen where the two words sit a
// thumb apart was the one screen that disagreed with itself.
//
// Same source as the tab now: "My Deliveries" on a meal run, "My
// Bookings" on a parcel route. See [ServiceProfile.jobNounPlural].
title: 'My ${ServiceProfile.active.workTabLabel}',
trailing: MapViewRow(
pickup: _picked,
preservedStepNumbers: _preservedStepNumbers,
),
),
// ── This screen is cards on a ground; Home is rows on a surface ──
//
// Deliveries is the one work screen whose content genuinely *is* a
// stack of raised objects: the NOW card is a layer-2 command surface,
// and UP NEXT and LATER recede beneath it. A layer-2 surface needs a
// layer-0 ground to sit on, and this page was giving it white — so the
// card that is supposed to dominate the screen was separating from its
// page by 1.124 : 1, and had to carry a border and a shadow to be seen
// at all.
//
// Home and Activity keep the working layer, because they have no cards
// by design — rows on a spine. Same ladder, different rung, and the
// rung is chosen by what the screen actually draws. See
// [MilerSurface].
body: MilerSheet(
color: MilerSurface.canvas,
child: Stack(
children: [
// Three states, cross-faded: still asking, nothing to do, and the
// queue itself. The middle one used to cover the first, and the
// hand-off between any two of them used to be a single frame.
SmoothSwap(
child: _picked.isEmpty && !_firstLoadDone
? const SkeletonList(key: ValueKey('bookings-loading'))
: _picked.isEmpty && _fetchFailed
? ErrorRetry(
key: const ValueKey('bookings-error'),
onRetry: () => _fetchPicked(force: true),
)
: _picked.isEmpty
? SingleChildScrollView(
key: const ValueKey('bookings-empty'),
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
// ── The artwork is the whole empty state ──
//
// This was a [MilerEmptyState] drawing a headline and a
// body line above the illustration — and the
// illustration has both baked into it, in the same
// words: "You're all caught up", then the line about
// new jobs appearing automatically. Rendered together
// the screen said each sentence twice, which reads as a
// rendering fault rather than as one message. The panel
// is the picture now, at a size worth looking at, and
// it keeps its own copy.
//
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
// folds away entirely in release, so nothing ships
// underneath the art.
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
),
child: Image.asset(
'assets/images/caught_up.png',
width: 330.w,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
LucideIcons.truck,
size: 64.sp,
color: ColorConstants.borderStrong,
),
),
),
_fetchDiagLine(),
],
),
),
),
)
: Column(
key: const ValueKey('bookings-list'),
children: [
// No trip tabs here. Everything on this screen is already
// accepted, so the only live question is which stop is
// next — and that has one answer across the whole day.
// See [_visibleStops].
_buildTripRail(),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchPicked,
color: ColorConstants.primary,
// ── An empty ROW list, not an empty stop list ──
//
// This asked `_visibleStops.isEmpty`, but the rows
// are built from a filtered subset of it. When the
// stops were all finished-but-not-yet-cleared, the
// page took the populated branch and rendered a
// `ListView` with zero children: a blank screen
// under the rail, with no explanation and nothing
// to pull on. Ask the list that is actually drawn.
child: _queueRows().isEmpty
? _buildTripTabEmptyState()
: Builder(
builder: (context) {
final rows = _queueRows();
return ListView.builder(
padding: EdgeInsets.only(
top: 2.h,
// Room for the live strip only when
// one is actually drawn — see
// [_liveOnThisScreen].
// ── Room for whichever bar is
// drawn ──
//
// A flat 80 was the live strip's
// height, and the round bar is
// taller: a caption, a primary
// button and the nav-bar inset
// under it. The last card sat
// behind it with its controls
// unreachable. Measured from the
// pieces rather than guessed, so it
// cannot drift when either grows.
// ── One measured floor ──
//
// The unreleased branch was built
// from its bar's own pieces; the
// live branch was a flat `80.h`
// that forgot the device inset
// entirely, so on a gesture-nav
// phone the last row of the run sat
// behind the banner *and* behind
// the navigation. Both surfaces now
// state their own height and the
// floor reserves whichever one is
// actually showing, plus the inset.
bottom: _bottomReservation(context),
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
if (row.isHeading) {
return _QueueHeading(
label: row.heading!,
count: row.count,
// On the first heading only:
// it describes the whole
// queue, not the section.
order: index == 0
? _orderSource
: null,
);
}
final item = row.stop!;
final orderId =
(item['orderid'] ?? '')
.toString();
// Live metres + speed drive the
// distance / ETA on every density;
// RouteMetricsHelper picks the unit
// and falls back to an urban average
// when the GPS reading is unusable.
final distanceMeters =
RouteMetricsHelper.metersToStop(
item,
riderLat: _currentLocation
?.latitude,
riderLng: _currentLocation
?.longitude,
// The leg decides the end:
// a delivery row measures
// to the DOOR. It measured
// to the kitchen, so the
// figure grew as the rider
// approached the customer.
toDrop: MilkRun.workingKind(
item,
collectedIds: _collectedIds,
).isDelivery,
);
void open() => StopDetailSheet.show(
context,
stop: item,
stopNumber: row.displayStep,
// The pairing recorded at the
// counter — same store as the
// card, so the two agree.
bag: row.bag,
// The carried set — what turns
// a raw pickup row into the
// delivery leg the sheet must
// describe.
collectedIds: _collectedIds,
riderLat:
_currentLocation?.latitude,
riderLng:
_currentLocation?.longitude,
);
// ── One card shape per density ──
//
// Not one card dimmed four ways. See
// [QueueDensity] for why the live stop
// is physically bigger rather than
// merely brighter.
final Widget child = switch (row
.density) {
QueueDensity.now => PickupCard(
key: ValueKey(
'pickup_$orderId',
),
item: item,
displayStep: row.displayStep,
distanceMeters: distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
bag: row.bag,
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
),
QueueDensity.next => _NextCard(
item: item,
bag: row.bag,
distanceMeters: distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
onOpen: open,
),
QueueDensity.later => _LaterRow(
item: item,
bag: row.bag,
distanceMeters: distanceMeters,
onOpen: open,
),
QueueDensity.skipped =>
_SkippedRow(
item: item,
bag: row.bag,
reason: row.reason,
onOpen: open,
onResume: () =>
_resumeSkipped(item),
),
};
return Reveal(
key: ValueKey('reveal_$orderId'),
delay: staggerDelay(index),
child: child,
);
},
);
},
),
),
),
],
),
),
// The live strip, shared with Home. It places itself clear of the
// floating nav bar, so it is pinned flush here and left to it.
//
// Only for a stop this screen can actually work. On a kitchen line
// a collection is Home's, in bulk, so a strip here offering to
// "continue" one was an invitation into a flow the rider is not
// supposed to be in — and the way most riders found it. See
// [_liveOnThisScreen].
// ── The hinge of the day, at the foot of the tab that holds the
// load ──
//
// It shares this slot with the live strip and yields to it: once a
// stop is running, "continue that stop" is the only thing the rider
// wants from the bottom of this screen, and a second bar under his
// thumb offering to start something else is how the wrong one gets
// pressed. See [startRound].
// ── One contextual slot, and the workflow advances through it ──
//
// These were two `if`s over the same corner. Logically exclusive
// already — a live stop suppresses the start bar, because a
// second bar under the rider's thumb offering to start something
// else is how the wrong one gets pressed — but they *swapped* by
// one disappearing and the other appearing in the same frame,
// which reads as the screen glitching rather than as **Start
// delivery → Continue this stop**.
//
// One switcher, one child at a time, on the app's own state
// duration. Keyed by which surface it is rather than by its
// contents, so a poll that returns the same live stop does not
// replay the transition.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSwitcher(
duration: DesignConstants.motionState,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: _liveOnThisScreen.isNotEmpty
? ActivePickupBanner(
key: const ValueKey('live'),
activePickups: _liveOnThisScreen,
onTap: (pickup) async {
await startPickup(pickup);
// Refresh after returning from map screen
if (mounted) {
await Future.delayed(
const Duration(milliseconds: 500),
);
_fetchPicked();
}
},
)
: _unreleased.isNotEmpty
? StartRoundBar(
key: const ValueKey('start-round'),
count: _unreleased.length,
busy: _starting,
onPressed: _startWholeRound,
)
: const SizedBox.shrink(key: ValueKey('none')),
),
),
],
),
),
),
);
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// START DELIVERY — one press, the whole load
///
/// The bar the rider hits after the last counter. It states what it is about to
/// act on — `5 orders in hand` — because the press releases every one of them,
/// and a control acting on a set the rider cannot see is one he learns not to
/// trust.
///
/// ── Not a slide ──
///
/// Slides are reserved for the writes a resting thumb must not be able to make:
/// "picked" is the rider asserting he is holding somebody's lunch, "delivered"
/// that he has handed it over. Setting off is neither irreversible nor a claim
/// about somebody else's property — pressing it early costs a hub board that
/// reads *active* a few minutes early — so it is a button, at the size of every
/// other primary action in the app.
///
/// It sits above the floating nav bar rather than under it: [BottomPage] owns
/// that inset, and using it here is what keeps the button clear of the tab
/// strip on a tall phone.
/// How much scroll extent the queue must leave under itself.
///
/// ── Why this is a function and not a number ──
///
/// It was two branches and a magic constant: the unreleased case was built
/// from its bar's own pieces, and the live case was a flat `80.h` that forgot
/// the device inset entirely — so on a gesture-navigation phone the last row of
/// the run sat behind the banner *and* behind the navigation, with its controls
/// unreachable and no way to scroll to them.
///
/// One rule, reading the same two conditions the slot itself reads, so the
/// floor and the surface can never disagree about which one is on screen. Each
/// surface states its own height, because the thing that overlays a list is the
/// only thing that knows how much of the list it covers.
double bottomReservationFor({
required double inset,
required bool live,
required bool unreleased,
double idle = 8,
}) {
if (live) return inset + ActivePickupBanner.maxHeight;
if (unreleased) return inset + StartRoundBar.maxHeight;
return inset + idle;
}
class StartRoundBar extends StatelessWidget {
/// The tallest this bar gets, excluding the device inset. See
/// [ActivePickupBanner.maxHeight] for why the surface declares its own.
static double get maxHeight => ButtonSizes.primary + 54.h;
final int count;
final bool busy;
final VoidCallback onPressed;
const StartRoundBar({
super.key,
required this.count,
required this.busy,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
16.w,
12.h,
16.w,
BottomPage.bottomInset(context) + 10.h,
),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
boxShadow: [
// The list scrolls under this, so the edge has to read as an edge — a
// shadow rather than a rule, for the same reason the route cards have
// one: a hairline is not there on a scratched screen in sun, and a
// rule across a scrolling list reads as a divider between two rows.
BoxShadow(
color: ColorConstants.slateText.withValues(alpha: 0.10),
blurRadius: 18,
offset: const Offset(0, -4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$count ${count == 1 ? 'order' : 'orders'} in hand',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
MilerButton(
label: 'Start delivery',
icon: LucideIcons.truck,
color: ColorConstants.primary,
loading: busy,
onPressed: busy ? null : onPressed,
),
],
),
);
}
}