Files
doormile_milderapp/lib/views/Dashboard/pickups/pickups.dart
2026-08-28 15:07:30 +05:30

3431 lines
145 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 'dart:io';
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/narrative.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/slide_action.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/service_day.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,
);
}
}
/// True when [stop] is part of the route the rider actually rode.
///
/// The completed store is not a list of finished deliveries — it is where every
/// order that left the rider's hands is filed, whatever ended it. A stop he
/// **declined** on Home is in there (`terminalStatus: 'rejected'`) and so is
/// one the office **cancelled**. Neither was ever ridden to, so neither belongs
/// on a progress rail: they inflate its count and they are drawn in states that
/// assert something false — `rejected` reads as work still ahead of him,
/// `cancelled` reads as green, completed work.
///
/// A **skip** is not in that group and is deliberately kept. It is a door he
/// went to and could not finish, which is unfinished business, and the rail has
/// an amber node for exactly that.
///
/// Top-level rather than a method so [_railStops]' rule can be exercised
/// without building the screen — the arithmetic it protects is four lines and
/// took a rider's whole rail with it when it was wrong.
bool wasRiddenTo(Map<String, dynamic> stop) {
final status = stopStatusOf(stop);
return !status.isRejected && !status.isCancelled;
}
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>>[];
/// Notices when the day underneath the queue has moved on.
///
/// [_fetchPicked] filters against the service day *as read when it ran*, so
/// the queue on screen is only ever as fresh as the last fetch. See
/// [_rolloverCheck].
final ServiceDayRollover _day = ServiceDayRollover();
/// Fires at the next local midnight. See [_armRollover].
Timer? _rollover;
/// This queue's stops, for today and no other day.
///
/// ── Why Deliveries needed the same filter Home just got ──
///
/// The two screens read the same call. `GET /miler/bookings` returns the
/// rider's whole **open** set, so a stop he picked up on Tuesday and never
/// closed came back on Wednesday, went through [WorkBoundary.deliveryQueue]
/// as perfectly workable — it *is* collected and it *is* unclosed — and sat
/// in this morning's round. Worse than a stale row: the bar at the foot of
/// this screen offers to start the whole round, so yesterday's leftovers
/// were work the rider could be sent out on.
///
/// The rule and every part of its reasoning is [ServiceDay.onlyToday],
/// shared with Home so the two screens cannot disagree about which day it
/// is. What is local is only *which* day — the one this screen is showing.
///
/// The completed store is not put through it: [getCompletedBookings] already
/// prunes itself to today on read.
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
_day.rollIfNeeded();
return ServiceDay.onlyToday(stops, now: _day.day, where: 'MYPICKUPS');
}
/// ── The day ending under a queue that is already open ──
///
/// [ServiceDay] answers "which day is this row" at read time, which is right
/// for the data and not sufficient for the screen: the filter runs at fetch
/// time. A rider who leaves this tab open through 23:59, or backgrounds the
/// app on it and picks the phone up at six, is looking at a queue filtered
/// against a day that has ended.
///
/// A timer to the next local midnight for the app left in the foreground,
/// and resume for every other case — both platforms suspend timers in the
/// background. Both land in [_rolloverCheck], which is idempotent. The same
/// arrangement Home and Activity use.
void _armRollover() {
_rollover?.cancel();
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
}
/// Turns the queue over when the service day has moved on.
void _rolloverCheck() {
if (!mounted) return;
_armRollover();
if (!_day.rollIfNeeded()) return;
debugPrint('[MYPICKUPS] the day rolled over to ${_day.day} — refetching');
_fetchPicked(force: true);
}
// ══════════════════════════════════════════════════════════════════════
// 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.
///
/// ── Why the rail was counting stops the rider never rode ──
///
/// The completed store is not a list of finished *deliveries*. It is where
/// every order that left the rider's hands is filed, whatever ended it — and
/// **a declined stop is filed there too** (`addCompletedBookings(...,
/// terminalStatus: 'rejected')`, from Home's decline). That was invisible for
/// as long as nothing read the store as a route.
///
/// This reads it as a route, and the arithmetic was plainly wrong: a rider
/// who took one order out of five and declined the other four got a rail of
/// **five** nodes. Worse than the count, each wore the wrong colour —
/// `rejected` maps to [StopProgress.pending], so four stops he had refused
/// were drawn ahead of him as work still to come, and a `cancelled` one is
/// [StopStatusX.isWorkComplete] and draws **green**, claiming a delivery that
/// never happened.
///
/// So the rail draws what he actually rode. A skip stays — it is unfinished
/// business and the rail has an amber node to say so, which is the whole
/// reason that colour exists. A stop he declined and a stop the office called
/// off are not on his route at all.
///
/// ── And it draws the trip it is labelled with ──
///
/// The strip is headed `Trip 2 · 10:00 AM – 1:00 PM` — see [_buildTripRail] —
/// while the store it reads holds the **whole day**, so on a second run the
/// heading named one trip and the nodes counted two. Completed stops are kept
/// to the trip in front of the rider whenever both numbers are known;
/// anything unplaced is kept, because dropping a stop for want of a trip
/// number would understate a day the rider really did work.
///
/// With no open stops there is no current trip and nothing is scoped away:
/// a rider who has just closed his last stop still sees the full green rail,
/// which is the case this getter was written for.
List<Map<String, dynamic>> get _railStops {
final open = _visibleStops;
if (_completedToday.isEmpty) return open;
final openKeys = open.map(_getOrderKey).toSet();
final trip = _currentTripNumber;
final done = <Map<String, dynamic>>[
// The store hands them back newest first; the rail reads in route order.
for (final o in _completedToday.reversed)
if (!openKeys.contains(_getOrderKey(o)) && wasRiddenTo(o))
if (trip == null || (_tripNumberOf(o) ?? trip) == trip) o,
];
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,
);
// ── The number is the slot, not the position in the list ──
//
// This read `i + 1` over the trips themselves, and it agreed with Home only
// by accident: while nothing could place a trip by day part, every run fell
// into one undated bucket and position and slot were the same number.
//
// They are not any more. [Trip.dayPartTimeOf] places a run in the part of
// the day it was assigned in, so a rider whose whole day is an afternoon
// run has one trip — at **slot 1**, which Home's tabs and the hub both call
// Trip 2, and which this method would have called Trip 1. That is precisely
// the failure the doc above says it exists to prevent, arriving through the
// arithmetic rather than through the input.
//
// [TripSlots.slots] is the same laying-out Home's tab bar uses, including
// its fallback to arrival order when the day parts cannot place every trip
// — so the two screens cannot produce different numbers for one stop
// without the tabs themselves being wrong.
final slots = trips.slots;
return <String, int>{
for (var i = 0; i < slots.length; i++)
if (slots[i] != null)
for (final id in slots[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> _orderLabels = const {};
Future<void> _loadOrderLabels() async {
final labels = await getOrderLabels();
if (!mounted) return;
setState(() => _orderLabels = 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),
),
label: _orderLabels[(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;
// A clock range only. The day part's name would print `Trip 1 · Morning`,
// which is the same fact twice — the number *is* the day part. See
// [Trip.slotClockLabel].
final clock = trip.slotClockLabel;
final window = clock.isEmpty ? null : clock;
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
_armRollover();
_restoreSkips();
_restoreClosedToday();
_restoreActivePickup();
_initializeLocation(); // Don't block
_fetchPicked();
_loadOrderLabels();
_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);
// Before the early return below: a resume that does not change the
// foreground flag still has to notice a midnight that passed while the
// phone was in the rider's pocket, which is the case the timer cannot
// cover — both platforms suspend timers in the background.
if (state == AppLifecycleState.resumed) _rolloverCheck();
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();
_rollover?.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,
];
// ══════════════════════════════════════════════════════════════════════
/// True while a round is being marked started, so [startRound] cannot run
/// twice over the same stop.
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, 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.
///
/// ── The bar that used to call this is gone ──
///
/// There was a **Start delivery** bar pinned above the nav on this screen:
/// `5 orders in hand`, one press, release the lot. It was removed at the
/// rider's request, and the argument for it does not survive contact with how
/// the round is actually ridden.
///
/// It existed because "I have set off" is a claim only the rider can make,
/// and that is still true — but he already makes it, once per stop, when he
/// slides **Start ride** on the map. That slider calls straight into this
/// method (see `map.dart`), so the release was never exclusive to the bar:
/// the bar was a second way to make the same claim, earlier, in bulk, for
/// stops he had not set off for yet.
///
/// Which is what made it worth removing rather than merely redundant. A
/// batch release says the whole load is on the road at a moment when at most
/// one bag is, so the hub's board read *active* for four stops still in the
/// rider's box — the exact failure the bar was introduced to fix, moved one
/// step later in the day. Releasing per stop, at the moment he rides to it,
/// is the honest version of the same act.
///
/// Nothing is stranded: a consignment that needs releasing is released when
/// he sets off for it, and one already released costs no request at all —
/// `releaseForDelivery` returns true on sight of a deliverable state.
///
/// Still takes a list, because the caller passes one and a batch of one is
/// the common case. 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>[];
for (final stop in stops) {
final orderId = MilkRun.idOf(stop);
if (orderId.isEmpty) continue;
if (await releaseForDelivery(stop)) released.add(orderId);
}
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);
}
}
/// Scroll extent to reserve under the queue for whatever floats over it.
///
/// Reads the same condition the slot itself reads, so the floor and the
/// surface can never disagree about what is on screen.
double _bottomReservation(BuildContext context) => bottomReservationFor(
inset: BottomPage.bottomInset(context),
live: _liveOnThisScreen.isNotEmpty,
idle: 8.h,
);
/// 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 does NOT start its leg ──
//
// This used to call `startRound([item])` here — the same server release the
// Start-ride slider makes — on the reasoning that a rider who taps a card
// without pressing the bar is just as much on his way, and that `deliver`
// refuses anything that is not `Out_for_Delivery`, so an unreleased stop
// would walk him to a door and fail him at it.
//
// ── Why that is the production lifecycle bug, one tap later ──
//
// [MilkRun.navigatesToCustomer] is true the moment a stop is in
// `_collectedIds`, which is the moment **Picked** succeeds. So the sequence
// that matters is:
//
// ```
// Home slide Picked → pickup-complete → Collected_By_Miler
// Work tap the card → start-delivery → Out_for_Delivery ← here
// Map slide Start ride (already released)
// ```
//
// Tapping a card is not a statement that the rider has set off. It is how
// he reads an address, checks a phone number, or looks at the route — and
// it fired the one call that is supposed to be the slider's alone, so the
// console went **Active** while the load was still on the counter and the
// rider had not slid anything.
//
// It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is
// off: the pivot has already released the consignment, so
// `releaseForDelivery` sees a deliverable state and returns true without
// spending a request. **The flag flip is what arms this.** Flipping it
// without this change moves the early-Active bug rather than fixing it.
//
// The door is still covered, and by the branch written for it: the
// `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the
// rider never started, at the moment he actually hands over, then re-reads
// the state before posting. That is the safety net this pre-release was
// duplicating — one tap too early and on the wrong side of the boundary.
//
// See `MilerLifecycle`, and the regression tests in
// `test/picked_does_not_release_test.dart`.
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(_loadOrderLabels());
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
};
}
}
var 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');
}
// ── Today's, and only today's ──
//
// Applied here, once, on the merged day: after both endpoints and the
// accepted store, and before anything is derived from it. Everything
// below reads this list — the trip numbering, the milk run's whole-day
// set, the delivery queue, the rail — so filtering at one boundary is
// what stops a count above the queue from totalling a different set of
// days than the queue under it.
//
// `_completedToday` is deliberately not put through it: the completed
// store prunes itself to today on read, and it was already correct.
//
// See [_todayOnly], and [ServiceDay.onlyToday] for the rule Home shares.
items = _todayOnly(items);
// ── 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.
// ── What the rider has already written off today ──
//
// Read from the two stores that record a mutation *after* it came back
// OK, so this is authoritative and not a display flag: `completed` holds
// delivered and cancelled, `skipped` holds a parked attempt. Both are
// day-stamped and scoped to this rider, tenant and line, and both survive
// a cold start — which is what stops `/miler/bookings` re-admitting a
// skipped stop on the next poll while the consignment is still
// `Out_for_Delivery`. See [WorkBoundary.isClosed].
final closedIds = <String>{
...await getCompletedOrderIds(),
...await getSkippedOrderIds(),
};
var dedupedList = WorkBoundary.deliveryQueue(
items.whereType<Map<String, dynamic>>(),
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
);
// 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()}',
);
// ── The poll-time re-stamp is gone ──
//
// This used to walk the list writing `orderstatus = 'skipped'` onto every
// row in the in-memory skip cache, so that the 3s poll could not revert a
// skip the backend had not recorded. That was a display flag papering
// over dual ownership: the row was still in the queue, still owned by
// this tab, and merely wearing a different word.
//
// A skipped order no longer reaches this point — `closedIds` drops it at
// the boundary, above both screens — so there is nothing left to stamp
// and no second opinion to keep in sync. See [WorkBoundary.isClosed].
// 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 corner has to have something behind it ──
//
// The sheet's two top corners were clipping onto a ground painted in
// the same canvas the sheet itself uses, so the curve revealed the
// page's own colour and was invisible: this tab read as a hard maroon
// line above the queue while Home and Activity carried the shape. The
// brand goes behind the sheet, which is what the other tabs do and
// what makes the curve a curve. The page's own colour is the sheet's
// (`MilerSurface.canvas`, one line below), not this.
backgroundColor: ColorConstants.primary,
appBar: MilerAppBar(
// The bar's curve opens onto the sheet's canvas, not the app's
// paper. See [milerBrandSurface].
pageColor: MilerSurface.canvas,
// ── 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,
// ── The sheet has to fill the body, not the queue ──
//
// A `Stack` hands its children loose constraints and then shrinks to
// the tallest of them, so on a short queue — or the empty state —
// the sheet ended where the content did and the rest of the body was
// whatever the Scaffold was painted with. That was invisible while
// the ground was the same canvas as the sheet; the moment the brand
// went behind it (for the corners) it became a red band across the
// bottom of the tab.
child: SizedBox.expand(
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.
label: row.label,
// 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,
label: row.label,
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
),
QueueDensity.next => _NextCard(
item: item,
label: row.label,
distanceMeters:
distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
onOpen: open,
),
QueueDensity.later => _LaterRow(
item: item,
label: row.label,
distanceMeters:
distanceMeters,
onOpen: open,
),
QueueDensity.skipped =>
_SkippedRow(
item: item,
label: row.label,
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();
}
},
)
: const SizedBox.shrink(key: ValueKey('none')),
),
),
],
),
),
),
),
);
}
}
/// 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, 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 condition the slot itself reads, so the floor and
/// the surface can never disagree about what is on screen. The 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.
///
/// ── One surface, where there were two ──
///
/// The other was the **Start delivery** bar, and it is gone with the control —
/// see the note above `startRound`. Nothing floats over this
/// list now except the live strip.
double bottomReservationFor({
required double inset,
required bool live,
double idle = 8,
}) => live ? inset + ActivePickupBanner.maxHeight : inset + idle;