// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast library; // ── Dead visual effects, removed ── // // This file imported `confetti`, `scratcher`, `circular_countdown_timer`, // `slider_button_lite` and `lottie` alongside the delivery widgets — the // remains of a celebratory completion flow. Every one of them had **zero** // references left in the file; they were kept alive only by their import // lines, which is how a scratch-card and a confetti burst stay one wiring // mistake away from a field-service queue. // // The packages stay in `pubspec.yaml` because other screens use some of them; // what is removed here is this file's claim on them. import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart' show kDebugMode, setEquals; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:miler/views/helpers/widgets/miler_map.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/helpers/poll_policy.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; import 'package:miler/views/Dashboard/home/trip_progress_rail.dart'; import 'package:miler/views/Dashboard/home/homepage_banner.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:miler/providers/pickup/pickup_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/controllers/pickups_controller.dart'; import 'package:miler/controllers/duty_controller.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:miler/widget/Bottom_page.dart'; import 'package:circular_countdown_timer/circular_countdown_timer.dart'; import 'dart:math' as math; import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/controllers/riderlog.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/consignment_state.dart'; import 'package:miler/data/proof_store.dart'; import 'package:miler/views/Dashboard/pickups/delivery_proof_page.dart'; import 'package:miler/data/mutation_guard.dart'; import 'package:miler/data/stop_compliance.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:scratcher/scratcher.dart'; import 'package:confetti/confetti.dart'; import 'package:miler/data/service_profile.dart'; // The two pages the logistics arrival opens: proof of what is being collected, // then the shipment desk that establishes where it is going and what it costs. import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; import 'package:miler/views/Dashboard/pickups/shipment_capture.dart'; import 'package:miler/views/Dashboard/pickups/shipment_review.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/route_order.dart'; import 'package:miler/data/stop_area.dart'; import 'package:miler/data/stop_contact.dart'; import 'package:miler/data/work_domain.dart'; import 'package:miler/data/work_repository.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/assignment_lookup.dart'; import 'package:miler/utils/external_navigation.dart'; part 'card.dart'; part 'map.dart'; part 'pip.dart'; part 'sheet.dart'; part 'map_btn.dart'; part 'multi_map.dart'; part 'done.dart'; part 'skip_sheet.dart'; part 'delivery_actions.dart'; // `part '../Cart/cartpage.dart';` — REMOVED FROM THE BUILD. // // 1,070 lines of a Cart screen that nothing in the app ever instantiated: // `Cartpage` had no route, no tab and no call site. It compiled into every // release, carried its own 30-second poller and its own copy of the stop card, // and — because it was never on screen — quietly rotted out of step with the // Bookings list it duplicated. // // The file is still on disk. Delete it once this repo is under version control // and you can get it back; until then it is simply out of the binary. /// Helper function to launch phone dialer - works in both debug and release builds /// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch Future 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 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 resumeSkippedStop(Map 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()) { Get.find().triggerRefresh(); } debugPrint('[RESUME] $orderId un-parked on the delivery leg'); return true; } final dc = Get.isRegistered() ? Get.find() : 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.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 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 navigateToPickupMap( BuildContext context, Map pickup, ) async { await openScreen( context, _PickupMapScreen( pickup: pickup, parentState: null, // No parent state when called from outside ), swipeToGoBack: false, ); } } class _MyPickupsState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { final PickupProvider _provider = PickupProvider(); final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider(); List> _picked = >[]; List> _activePickups = >[]; // Track active pickup for banner StreamSubscription? _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 _pickupTimers = {}; final Map> _pickupBasePayload = >{}; // Preserve original step numbers so they don't change when pickup are completed final Map _preservedStepNumbers = {}; 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 _completedOrderKeys = {}; // 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 _skippedOrdersCache = {}; static final Map _skippedOrderTimestamps = {}; static void _forgetSkip(String key) { _skippedOrdersCache.remove(key); _skippedOrderTimestamps.remove(key); } Future _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 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 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 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> _completedToday = >[]; // ══════════════════════════════════════════════════════════════════════ // 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> _allDayStops = >[]; Set _acceptedIds = {}; Set _collectedIds = {}; Set _outForDeliveryIds = {}; Set _notLoadedIds = {}; Set _rejectedIds = {}; /// Whether this rider's line delivers what it collects. bool get _isMilkRun => ServiceProfile.active.deliversToCustomer; /// What the rail draws: the whole run, finished stops included. /// /// ── Why this is not [_visibleStops] ── /// /// It was, and that is why the rail never moved. `_visibleStops` is the list /// of stops still to *work*, and a completed stop is removed from it the /// instant it is confirmed — so the rail was handed a set in which nothing /// could ever be `done`. Every node stayed grey, the scooter sat on the first /// one all day, and finishing a stop made the rail one node SHORTER instead /// of one node greener: the rider's only measure of progress went backwards /// as he made it. /// /// The completed stops come from the same store the Activity tab reads, so /// the two agree about what was finished, and they lead because they were /// done first. The open ones follow in the hub's order. List> get _railStops { final open = _visibleStops; if (_completedToday.isEmpty) return open; final openKeys = open.map(_getOrderKey).toSet(); final done = _completedToday .where((o) => !openKeys.contains(_getOrderKey(o))) .toList() .reversed // the store hands them back newest first .toList(); return [...done, ...open]; } /// Progress state for every stop on the accepted trip, in the admin's fixed /// order — what drives the red→green rail at the top of Bookings. /// /// Exactly one stop is ever [StopProgress.current]: whichever the rider is /// physically on, or failing that the first stop still to be done. Skipped /// stops keep their own state so they read as unfinished business rather /// than either done or next. /// /// The per-stop mapping is [stopProgressFor], which is pure and lives in /// `trip.dart` so it can be tested — this State cannot be pumped. Read its /// doc for why "physically on" has to be asked in both legs' vocabulary. List _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 _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 _numberTrips(List> allToday) { final byId = >{}; for (final s in allToday) { final id = (s['orderid'] ?? '').toString(); if (id.isEmpty) continue; byId.putIfAbsent(id, () => s); } if (byId.isEmpty) return const {}; final trips = Trip.groupIntoTrips( byId.values.toList(), hubLat: _currentLocation?.latitude, hubLng: _currentLocation?.longitude, ); return { for (var i = 0; i < trips.length; i++) for (final id in trips[i].orderIds) id: i + 1, }; } /// The trip a stop belongs to, or null when the day's shape is not known yet. int? _tripNumberOf(Map 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 get _queuedTripNumbers { final seen = {}; 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 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> 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 _bagLabels = const {}; Future _loadBagLabels() async { final labels = await getBagLabels(); if (!mounted) return; setState(() => _bagLabels = labels); } /// ── The queue, in the order it will be worked ── /// /// A delivery list where every card looks the same makes the rider decide /// which one is next — a decision the app already knows the answer to. So the /// list is cut into three, and only the first section is live: /// /// **NOW** — the one stop he is working. The only enabled card. /// **UP NEXT** — the one after it, so he can see where he is heading. /// **LATER** — the rest, in order. /// /// Skipped stops sit above NOW, under their own heading: they are earlier in /// route order and still resumable, and burying them under LATER would hide /// work he has already been to once. /// /// This adds no new sequencing rule. Which card is live was already decided /// here — the first non-skipped stop, and nothing while another is running — /// and the sections simply say out loud what that logic already meant. List<_QueueRow> _queueRows() { // ── `isWorkComplete`, not `isPicked` ── // // This dropped every stop whose raw rung was `picked`. On a parcel route // that is correct by accident — collection IS the end of the job there. On // a **milk run** `picked` is the middle of the morning: the rider is // holding fifteen lunches and has not delivered one of them. // // So collecting a crate at the kitchen made the card vanish off this screen // while the rail above it — which correctly reads the profile-aware // predicate — kept drawing the stop as a node. That is the exact symptom: // a rail with a stop on it and no card underneath. // // [StopStatusX.isWorkComplete] is the one that asks "should this still be // worked today?", and its own doc says to read it here. Nothing else on // this screen was using the raw rung. final workable = [ for (final s in _visibleStops) if (!stopStatusOf(s).isCancelled && !stopStatusOf(s).isWorkComplete) s, ]; if (workable.isEmpty) return const []; final hasActive = _hasActivePickup(); final liveIndex = workable.indexWhere((s) => !stopStatusOf(s).isSkipped); final rows = <_QueueRow>[]; var skippedSeen = false; var laterSeen = false; for (var i = 0; i < workable.length; i++) { final stop = workable[i]; final skipped = stopStatusOf(stop).isSkipped; final before = i < (liveIndex < 0 ? workable.length : liveIndex); QueueDensity density; if (skipped) { density = QueueDensity.skipped; } else if (i == liveIndex && !hasActive) { density = QueueDensity.now; } else if (i == liveIndex || i == liveIndex + 1) { // While another stop is already running there is no live card at all — // the one he is working is on its own screen — so the head of the queue // reads as "next" rather than pretending to be actionable. density = QueueDensity.next; } else { density = QueueDensity.later; } if (skipped && !skippedSeen && before) { skippedSeen = true; rows.add(const _QueueRow.heading('SKIPPED')); } else if (density == QueueDensity.now) { rows.add(const _QueueRow.heading('NOW')); } else if (!skipped && i == liveIndex + (hasActive ? 0 : 1)) { rows.add(const _QueueRow.heading('UP NEXT')); } else if (density == QueueDensity.later && !laterSeen) { laterSeen = true; rows.add(_QueueRow.heading('LATER', count: workable.length - i)); } rows.add( _QueueRow.stop( stop: stop, density: density, displayStep: _getDisplayStepNumber( _visibleStops, _visibleStops.indexOf(stop), ), bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '', reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '', ), ); } return rows; } /// Puts a skipped stop back in the queue. /// /// The work itself is [MyPickups.resumeSkippedStop] — a static, because three /// screens have owned this control in turn and the logic moved with it every /// time. This is only the screen-local half: refresh the list so the row /// leaves SKIPPED and rejoins the queue in route order. Future _resumeSkipped(Map stop) async { final ok = await MyPickups.resumeSkippedStop(stop); if (!mounted || !ok) return; AppFeedback.successGlobal('Back in your queue'); await _fetchPicked(force: true); } /// Shown when the rider has accepted work but none of it is outstanding. /// /// With the trip tabs gone this is a much simpler state than it was: there is /// no longer a "this tab is empty but the others are not" case to explain, /// because there are no tabs to be on the wrong one of. Either there is work /// on this screen or the day is done. /// /// Must stay scrollable — it is the child of a `RefreshIndicator`, and /// pull-to-refresh is the rider's way out of it. Widget _buildTripTabEmptyState() { return ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), padding: EdgeInsets.fromLTRB(24.w, 48.h, 24.w, 24.h), children: [ Center( child: Container( width: 62.w, height: 62.w, decoration: BoxDecoration( color: ColorConstants.neutralLight, shape: BoxShape.circle, border: Border.all(color: ColorConstants.borderStrong), ), child: Icon( LucideIcons.circleCheck, size: 28.sp, color: ColorConstants.acceptGreen, ), ), ), SizedBox(height: 14.h), Text( 'All your stops are done', textAlign: TextAlign.center, style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w700, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 6.h), Text( 'Anything you accept on Home lands here. Pull down to refresh.', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.5.sp, height: 1.45, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), _fetchDiagLine(), ], ); } /// The last fetch's stage counts, shown under an empty state in debug only. /// Never compiled into release — [kDebugMode] is a const, so the whole widget /// folds away. Widget _fetchDiagLine() { if (!kDebugMode || _fetchDiag.isEmpty) return const SizedBox.shrink(); return Padding( padding: EdgeInsets.only(top: 18.h), child: Text( 'debug · $_fetchDiag\n' '${_bookingTrips.length} trip(s) · showing ${_visibleStops.length}', textAlign: TextAlign.center, style: TextStyle( fontSize: 10.sp, height: 1.5, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ); } Widget _buildTripRail() { // The whole run, finished stops included — see [_railStops]. Drawn as long // as *anything* happened today, so a rider who has just closed his last // stop still sees the full green rail rather than the strip vanishing at // the moment it finally reads 100%. final railStops = _railStops; if (railStops.isEmpty) return const SizedBox.shrink(); final states = _tripProgress(); final trip = Trip.fromStops( id: 'active', stops: railStops, hubLat: _currentLocation?.latitude, hubLng: _currentLocation?.longitude, ); // Time left is only the work still outstanding — counting finished stops // would keep the estimate flat all day and make it useless. var remaining = Duration.zero; for (var i = 0; i < railStops.length && i < states.length; i++) { if (states[i] == StopProgress.done) continue; remaining += Trip.serviceTimeFor(railStops[i]); remaining += trip.travelTimeToStop( i, hubLat: _currentLocation?.latitude, hubLng: _currentLocation?.longitude, // Door to door on a round — the walk over the pickup pairs was a // tour of kitchens already behind him. See [Trip.travelTimeToStop]. deliveryLeg: _isMilkRun, ); } // ── Name the trip he is riding ── // // The rail headline read "10:00 AM – 1:00 PM", or "Your route" when the // backend sent no slot — neither of which answers the question the rider // actually asks himself on this screen, which is *which of today's trips am // I on*. He is given three, they are numbered on Home, the hub refers to // them by number on the phone, and this screen was the one place in the // flow that would not say. // // The number leads and the window follows it, because the number is the // shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM". final tripNo = _currentTripNumber; final window = trip.slotStart != null ? trip.slotLabel : null; final label = tripNo == null ? (window ?? 'Your route') : window == null ? 'Trip $tripNo' : 'Trip $tripNo · $window'; return TripProgressRail( progress: states, slotLabel: label, remaining: remaining > Duration.zero ? remaining : null, ); } String _distanceKmDisplay(Map 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 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> _sortOrders(List> 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> 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() ? Get.find() : Get.put(PickupsController()); ever(dc.refreshTrigger, (_) { if (mounted) { debugPrint( '[MYPICKUPS] External trigger -> Refreshing picked bookings', ); _fetchPicked(force: true); } }); } catch (_) {} // ── Re-read whenever this tab comes forward ── // // The shell keeps all four tabs alive in an `IndexedStack`, so arriving // here is not a build — it is an index change, and nothing about it asks // this screen for fresh data. The poller does eventually, up to three // seconds later, which is exactly long enough for a rider who has just // accepted a stop on Home to arrive, find it missing, and conclude the // accept failed. Activity carries the same listener for the same reason. BottomPage.currentTab.addListener(_onTabChanged); // Run these in parallel _restoreSkips(); _restoreClosedToday(); _restoreActivePickup(); _initializeLocation(); // Don't block _fetchPicked(); _loadBagLabels(); _startPollingStream(); } /// This screen's slot in the shell. See [BottomPage.goToTab]. static const int _bookingsTabIndex = 1; void _onTabChanged() { if (BottomPage.currentTab.value == _bookingsTabIndex && mounted) { _fetchPicked(force: true); } } /// Re-loads today's skips into [_skippedOrdersCache] after a cold start. /// /// The cache is static but not persistent, so killing the app used to lose /// every skip taken that shift: the stop came back from the queue with /// whatever status the backend last recorded and dropped straight back into /// the working list, ahead of stops the rider had not skipped. The store that /// Activity reads is the same one, so both screens now agree after a restart. Future _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 _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 _restoreActivePickup() async { try { final prefs = await SharedPreferences.getInstance(); final savedActiveId = prefs.getString('active_pickup_order_id'); if (savedActiveId != null && savedActiveId.isNotEmpty) { _activePickupOrderId = savedActiveId; debugPrint( '[MYPICKUPS] Restored active pickup orderId: $savedActiveId', ); } } catch (e) { debugPrint('[MYPICKUPS] Error restoring active pickup: $e'); } } /// Restarts the poller at the speed the rider's current situation deserves. /// /// Cheap to call: if the interval has not changed, the running stream is left /// alone rather than torn down and rebuilt (which would reset the clock and, /// called from a fetch handler, could starve the poll entirely). void _syncPolling() { if (!_pollForeground) { _pollerSubscription?.cancel(); _pollerSubscription = null; _currentPollInterval = null; return; } final Duration interval = pollInterval( hasLiveWork: _activePickups.isNotEmpty, onDuty: _onDuty, ); if (_pollerSubscription != null && _currentPollInterval == interval) return; _pollerSubscription?.cancel(); _currentPollInterval = interval; _pollerSubscription = Stream.periodic(interval, (_) {}) .asyncMap((_) async { if (mounted && !_fetching) { await _fetchPicked(); } }) .listen( (_) {}, onError: (error) { debugPrint('[MYPICKUPS][STREAM ERROR] $error'); }, cancelOnError: false, ); } void _startPollingStream() => _syncPolling(); /// The screen stays awake only while a stop is actually live. /// /// It used to be held for the whole time Bookings was open — so a rider with /// the phone in his pocket and nothing running had a screen that would not /// sleep, which is the fastest way to flatten a battery mid-shift. Worse, it /// was held *here*, on the list, and not on the map screen where he is /// actually navigating and genuinely needs it. void _syncWakelock() { final bool keepAwake = _activePickups.isNotEmpty; if (keepAwake == _wakelockHeld) return; _wakelockHeld = keepAwake; if (keepAwake) { WakelockPlus.enable(); } else { WakelockPlus.disable(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); final bool foreground = state == AppLifecycleState.resumed; if (foreground == _pollForeground) return; _pollForeground = foreground; if (foreground) { // One catch-up fetch immediately, so he never comes back to a queue that // was true a minute ago, then the normal cadence. _fetchPicked(force: true); _syncPolling(); } else { _syncPolling(); // Nothing is being watched, so nothing needs the screen. if (_wakelockHeld) { _wakelockHeld = false; WakelockPlus.disable(); } } } /// Duty state decides the idle poll rate. Read from the same pref the duty /// toggle writes, so Bookings and Home agree without a second source. Future _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 _initializeLocation() async { try { // 1. Try Last Known Position (Instant & Preferred) try { final lastPos = await Geolocator.getLastKnownPosition(); if (lastPos != null && mounted) { setState(() { _currentLocation = lastPos; }); // If we have a cached location, don't block waiting for fresh GPS // We can let the background stream update it later return; } } catch (_) {} // 2. Try Current Position (Optimized) final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) return; LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) { return; } // Single attempt with balanced accuracy/timeout try { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 3), ), ); if (mounted) { setState(() => _currentLocation = position); } } catch (_) { // Fallback to low accuracy if high fails try { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, timeLimit: Duration(seconds: 2), ), ); if (mounted) { setState(() => _currentLocation = position); } } catch (_) {} } } catch (e) { debugPrint('[MYPICKUPS] Error initializing location: $e'); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); BottomPage.currentTab.removeListener(_onTabChanged); _pollerSubscription?.cancel(); for (final timer in _pickupTimers.values) { timer.cancel(); } _pickupTimers.clear(); _pickupBasePayload.clear(); // Persist current live pickup state for other screens SharedPreferences.getInstance() .then((p) => p.setBool('has_live_pickup', _picked.isNotEmpty)) // ignore: body_might_complete_normally_catch_error .catchError((_) {}); if (_wakelockHeld) WakelockPlus.disable(); super.dispose(); } bool _shallowMapEquals(Map a, Map 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> oldList, List> 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 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> get _liveOnThisScreen => [ for (final p in _activePickups) if (MilkRun.worksOnOwnScreen(p, collectedIds: _collectedIds)) p, ]; // ══════════════════════════════════════════════════════════════════════ // START DELIVERY — the load leaves the counter // // ── Why this is a press and not a side effect of the pickup ── // // The release used to ride along with `pickup-complete`, back on Home: the // rider slid to confirm five bags at a kitchen and the same gesture posted // all five consignments `Out_for_Delivery`. The hub's board then showed five // *active* deliveries for food still on the shelf in front of him, and there // was no moment left in the day that meant "I have set off" — picked and // out-for-delivery had been collapsed into one claim. // // They are two different claims about where the load is, made minutes or an // hour apart, and only the rider can make the second one. So it is his press, // on the tab that holds the load. // // ── Why one bar for the whole load ── // // `POST /miler/deliveries/start` takes a batch because the gesture is one // press after the last counter. Releasing per card would be five presses for // one act and would leave the round half-open — some rows deliverable, some // refused at the door by a server the rider cannot argue with. // ══════════════════════════════════════════════════════════════════════ /// Orders in the rider's hands that he has not yet declared himself riding. /// /// The bar's subject, and the reason it appears at all: an empty answer means /// the whole load is already on the road and there is nothing left to press. /// /// Empty on every line but the milk run, and deliberately: a logistics parcel /// is collected *for the hub* and delivered by somebody else, so its rider /// has no round to start and must never be shown a control that says he has. /// [MilkRun.navigatesToCustomer] asks the same question one stop at a time. List> get _unreleased => !_isMilkRun ? const [] : [ for (final s in _visibleStops) // The same three exclusions the queue itself applies, so the bar // can never count a row the rider cannot see: work he has already // closed, work he has put down, and work already on the road. if (!stopStatusOf(s).isWorkComplete && !stopStatusOf(s).isCancelled && !stopStatusOf(s).isSkipped && !_completedOrderKeys.contains(_getOrderKey(s)) && _needsRelease(s)) s, ]; /// Whether this stop is still waiting on the rider's **Start delivery**. /// /// ── Read from the consignment, not from a flag in this app ── /// /// Two backend behaviours are live at once and the app must not care which /// it is talking to. `pickup-complete` either leaves a hyperlocal /// consignment `Out_for_Delivery` (as it does today) or stops it at /// `Collected_By_Miler` and waits for `start-delivery` — a server-side /// setting, not something this app is told about. /// /// So the row is asked. `consignmentstatus` ships on every /// `GET /miler/bookings` row, and it answers the question exactly: needing /// release means needing release, and already out for delivery means the bar /// has nothing to offer. **No feature flag is mirrored here**, because a /// mirrored flag is a second source of truth that can be wrong. /// /// The local sets are the fallback for a row that carries no consignment /// status yet — an older deployment, or a stop collected seconds ago that /// the queue has not caught up with. bool _needsRelease(Map stop) { final state = consignmentStateFromRaw(stop['consignmentstatus']); if (state != ConsignmentState.unknown) { return state.needsRelease; } final id = MilkRun.idOf(stop); return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id); } /// True while the round is being marked started, so the bar cannot be /// double-pressed. bool _starting = false; /// Sets off: releases each collected consignment for delivery, server first. /// /// ── This is a real write now ── /// /// It has been three different things, and the history matters because two /// of them were wrong in opposite directions. /// /// It began as `POST /miler/deliveries/start` — a route that is not on the /// contract and not in the backend's table. It 404'd every time, so the bar /// could never succeed. It was then cut back to a purely local record, on /// the evidence that `pickup-complete` was itself pushing hyperlocal work /// to `Out_for_Delivery`: the load really was released before it reached /// this tab, and the press only recorded what the rider had done with it. /// /// The backend closed that gap on 21 Aug 2026. `pickup-complete` now stops /// at `Collected_By_Miler` — collected, in the rider's hands, not yet on the /// road — and `POST /miler/consignments/:id/start-delivery` makes the /// release. So the press has a server meaning again, and the hub stops /// seeing "out for delivery" for food still on the kitchen counter. /// /// **The local set is written only for what the server released.** A stop /// whose consignment could not be reached or was refused keeps its PICKED /// word and stays on the bar, because the alternative — showing ACTIVE for /// a consignment the hub still calls collected — is the fiction that made /// `deliver` fail at the door with nothing on screen to explain why. /// /// Returns the number of orders actually released. Future startRound(List> stops) async { if (_starting || stops.isEmpty) return 0; setState(() => _starting = true); try { final released = []; var unreachable = 0; for (final stop in stops) { final orderId = MilkRun.idOf(stop); if (orderId.isEmpty) continue; if (await releaseForDelivery(stop)) { released.add(orderId); } else { unreachable++; } } _unreleasedFailures = unreachable; if (released.isEmpty) return 0; await addOutForDeliveryOrderIds(released); await stampOrderEvents(released, OrderEvent.outForDelivery); if (mounted) { setState( () => _outForDeliveryIds = {..._outForDeliveryIds, ...released}, ); } return released.length; } catch (e) { debugPrint('[MILKRUN] could not record the round starting: $e'); return 0; } finally { if (mounted) setState(() => _starting = false); } } /// How many stops the last [startRound] could not release. Read once by /// [_startWholeRound] to word the message honestly. int _unreleasedFailures = 0; /// Scroll extent to reserve under the queue for whatever floats over it. /// /// Reads the same two conditions the slot itself reads, so the floor and the /// surface can never disagree about which one is on screen. double _bottomReservation(BuildContext context) => bottomReservationFor( inset: BottomPage.bottomInset(context), live: _liveOnThisScreen.isNotEmpty, unreleased: _unreleased.isNotEmpty, idle: 8.h, ); Future _startWholeRound() async { final stops = _unreleased; final n = await startRound(stops); if (!mounted) return; final held = _unreleasedFailures; if (n == 0) { AppFeedback.errorGlobal( held == 1 ? 'Could not start that delivery. Check your connection and try ' 'again.' : 'Could not start the round. Check your connection and try again.', ); return; } AppFeedback.successGlobal( held > 0 ? '$n started · $held could not be started' : (n == 1 ? 'Delivery started' : '$n deliveries started'), ); } /// Marks a stop as finished (picked up or cancelled) in local state right /// away, so the UI unblocks without waiting for the backend/poll to catch up: /// it leaves the active banner, drops out of the top list, and no longer /// blocks the next stop. void markPickupFinished( Map 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() ? Get.find().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.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> 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 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 startPickup(Map item) async { if (!mounted) return; // ── A collection is not worked from this screen ── // // On a kitchen line the rungs up to *picked up* belong to Home, in bulk: // the rider is handed a stack of bags at one counter and slides once for // all of them. Opening this stop's own map/arrive/confirm screen for a // collection gave him a second, contradictory way to work the same rung — // and it posted the booking ACTIVE from a tab that is supposed to be his // load, so a stop he had not collected started reporting as live. // // Refused before the status write below, not after it. See // [MilkRun.worksOnOwnScreen]. if (!MilkRun.worksOnOwnScreen(item, collectedIds: _collectedIds)) { AppFeedback.info( context, 'Collections are confirmed on Home — select the orders at the ' 'kitchen and slide to update.', ); return; } // Online-gated: only an on-duty rider can start a pickup/delivery. if (!await ensureOnlineForAction()) return; if (!mounted) return; // ✅ BLOCK: Check if there's already an active pickup (and this isn't it) final currentOrderId = (item['orderid'] ?? '').toString(); final activeOrderIds = _activePickups .map((d) => (d['orderid'] ?? '').toString()) .where((id) => id.isNotEmpty) .toSet(); // Allow if this IS the active pickup, block if there's a different active pickup if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) { // Just ignore taps on other cards when a different active pickup exists. return; } // Verify status is updated to ACTIVE before navigating final dc = Get.put(PickupsController()); final dId = int.tryParse((item['pickupid'] ?? 0).toString()) ?? 0; final ohId = int.tryParse((item['orderheaderid'] ?? 0).toString()) ?? 0; if (dId > 0 && ohId > 0) { // We don't block navigation on failure, but we try to update // This ensures 'starttime' is generated and saved await dc.updateActiveStatus( pickupId: dId, orderHeaderId: ohId, orderId: (item['orderid'] ?? '').toString(), ); } // ── Opening a stop starts its leg, if the bar has not already ── // // START DELIVERY is the normal way in and releases the whole load at once. // This is the other way in: a rider who taps a card without pressing the // bar is just as much on his way, and the stop has to be released before he // gets there — `deliver` refuses any consignment that is not // `Out_for_Delivery`, so opening one unreleased would walk him to a door // and fail him at it. // // The same call the bar makes, for one order. [startRound] writes the local // marker only after the server agrees, so a failure here leaves the row // reading PICKED rather than lying about a round the hub has not started. if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) && !_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) { await startRound([item]); if (!mounted) return; } await openScreen( context, _PickupMapScreen(pickup: item, parentState: this), swipeToGoBack: false, ); } Future resumePickup(Map 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 _fetchPicked({bool force = false}) async { if (_fetching) { if (force) { _fetching = false; } else { return; } } _fetching = true; // The bag each order is in, re-read with the list it labels: a pickup that // happened on Home a moment ago must show its bags here without the rider // pulling to refresh. unawaited(_loadBagLabels()); try { final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; // ✅ CRITICAL: Fetch from v3 API (picked bookings) AND v2 API (all statuses) IN PARALLEL // Define V1 fetcher function (current/in-progress bookings) Future> 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> 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 []; } } final results = await Future.wait([fetchV3(), fetchV1()]); final itemsV3 = results[0] as List; final itemsV1 = results[1] as List; // Merge v3 bookings with v1 bookings (current/in-progress) final Map> mergedOrders = {}; // First add v3 bookings (picked/pickuped) for (final Booking in itemsV3.whereType>()) { final key = _getOrderKey(Booking); mergedOrders[key] = Booking; } // Then add/update with v1 bookings - V1 STATUS TAKES PRECEDENCE for (final Booking in itemsV1.whereType>()) { final key = _getOrderKey(Booking); final existing = mergedOrders[key]; if (existing == null) { mergedOrders[key] = Booking; } else { // If it exists in V3 (picked) but V1 says something else, trust V1. // Especially for 'skipped', 'Picked up', 'cancelled'. mergedOrders[key] = { ...existing, ...Booking, // Overwrite with V1 data }; } } final items = mergedOrders.values.toList(); debugPrint('[MYPICKUPS] Merged items count: ${items.length}'); // Today's finished stops, for the rail. Read on every fetch rather than // held from the last completion, so the rail is right after a restart // mid-shift too. _completedToday = await getCompletedBookings(); // Merge in bookings accepted from Home (offline/mock accept flow) so the // Bookings tab count grows as the rider accepts. final acceptedBookings = await getAcceptedBookings(); if (acceptedBookings.isNotEmpty) { final existingKeys = items.map(_getOrderKey).toSet(); int added = 0; for (final Booking in acceptedBookings) { if (existingKeys.add(_getOrderKey(Booking))) { items.add(Booking); added++; } } debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home'); } // ── The day's shape, before any of this screen's filtering ── // // Everything today: what is assigned, what he took, what he finished. // This is the only point in the fetch where all three are in one list, // and it is what makes "Trip 2" mean the same thing here as it does on // Home. See [_tripNumberByOrderId]. _tripNumberByOrderId = _numberTrips([...items, ..._completedToday]); // Admin-assigned bookings the rider hasn't accepted yet belong on the // Home tab, not here. Keep only bookings the rider has actually taken on: // those locally recorded as accepted, or already in a post-accept backend // state. Anything still pending/assigned is dropped from Bookings. final acceptedIds = await getAcceptedOrderIds(); // ── This screen does not decide what it owns ── // // [WorkBoundary] does, for both work screens at once, out of the shared // day. That is the whole point of it: the gate used to be written here as // a `where` over the raw statuses, Home decided the same thing its own // way, and the two could — and did — both claim an accepted booking. // // Where the boundary sits still follows the line, exactly as it did: // logistics hands over at acceptance, a kitchen line at pickup-complete. // What changed is that the rule is declared once, on // [ServiceProfile.handoffAt], instead of being re-derived on each screen. // // ── What this gate is deliberately NOT ── // // It is not the *released* set. Being on this tab and being deliverable // are two different things: the round does not open until the rider // presses START DELIVERY. That gate lives on the rows and on the bar at // the foot of this screen — see [_deliveryLocked] and [MilkRun] — rather // than on the fetch, because hiding the orders until release left them on // no tab at all: dropped from Home as collected, withheld from here as // unreleased. final Set 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>().toList(); _acceptedIds = acceptedIds; // `_collectedIds` is set above, for every line. _outForDeliveryIds = outForDeliveryIds; _notLoadedIds = notLoadedIds; _rejectedIds = rejectedIds; }); } } // One call, one rule — see [WorkBoundary.deliveryQueue]. The second // `where` that used to sit here, re-admitting rows by raw status, is gone // with it: it was the hole an accepted-but-uncollected booking came // through, and a second expression of a fact that now has one. var dedupedList = WorkBoundary.deliveryQueue( items.whereType>(), collectedIds: collectedIds, acceptedIds: acceptedIds, ); // Drop stops the rider just finished. The backend may still return them // as 'active'/'picked' for a poll cycle or two; without this they'd // reappear on the list and re-block the next stop. if (_completedOrderKeys.isNotEmpty) { dedupedList.removeWhere( (o) => _completedOrderKeys.contains(_getOrderKey(o)), ); } // Preserve step numbers for existing bookings for (final Booking in dedupedList) { final orderKey = _getOrderKey(Booking); final currentStep = _getStepNumber(Booking); if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) { _preservedStepNumbers[orderKey] = currentStep; } } // Sort the list final sortedList = _sortOrders(dedupedList); // Definitive per-order status dump — tells us exactly why an order shows // (or doesn't): e.g. "1234:picked" means it's completed, not assignable. debugPrint( '[MYPICKUPS] fetched statuses=' '${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}', ); // Re-apply locally-skipped orders so a skip survives polling even when the // backend hasn't recorded it yet (e.g. mock/offline data). Without this, // the 3s poll would overwrite the skip with the raw API status. Applied // before the filter below, because the filter now acts on it. if (_skippedOrdersCache.isNotEmpty) { for (final o in sortedList) { if (_skippedOrdersCache.containsKey(_getOrderKey(o))) { o['orderstatus'] = 'skipped'; } } } // Build the top list = the stops he is actually working. // - ACTIVE stops go to the banner, not the list. // - FINISHED stops (picked / cancelled) must be excluded entirely, not // merely hidden per-card. Otherwise the header/route-strip count // (_picked.length) included completed stops the cards then rendered as // SizedBox.shrink — a "2 stops" ghost with no visible cards. They are // on the Activity tab. // - SKIPPED stops **stay**, under their own heading. // // They used to leave, and the reason was sound at the time: left in // the list they sat among the live stops looking identical to them, // and the rider had to remember which of his eight cards were real. So // they were sent to Home to wait with the undecided work. // // That reason is gone. The queue is no longer one card repeated — a // skipped stop now renders as its own density (see [QueueDensity]): // under a SKIPPED heading, carrying the reason in warning colour, with // an outlined Resume and no primary action. It cannot be mistaken for // live work at a glance, which was the entire objection. // // Keeping it here is also where the rider looks for it. He skipped it // twenty minutes ago on this screen, in this queue; sending it to a // different tab to be resumed is a hunt for something he never moved. final listForTop = sortedList.where((o) { final st = stopStatusOf(o); return !st.isActive && !st.isFinishedPickup; }).toList(); debugPrint( '[MYPICKUPS] Final top list: ${listForTop.length} ' '(skipped held on Home: ${_skippedOrdersCache.length})', ); // Check if data has actually changed before rebuilding // This avoids unnecessary setState calls during polling // Debug-only trace of every stage this list passes through. Three // separate causes have made this tab look empty — a gate on the raw API // count, a missing empty state on an unfilled trip tab, and stops // filtered out as still-pending — and every one of them looked identical // on screen. The counts are now shown on the empty state itself, so the // failing stage is visible instead of guessed at. _fetchDiag = // Which line the app resolved the rider onto. Three of the reports // that landed here as "the tab is wrong" were really "the app thinks // I am on the other line", and nothing on screen said so. 'line=${ServiceProfile.active.line.name} ' 'api=${items.length} ' 'accepted=${acceptedIds.length} ' 'workable=${dedupedList.length} ' 'sorted=${sortedList.length} ' 'list=${listForTop.length} ' 'active=${sortedList.where((o) => stopStatusOf(o).isActive).length}'; debugPrint('[MYPICKUPS][DIAG] $_fetchDiag'); if (_fetchFailed && mounted) setState(() => _fetchFailed = false); final bool hasChanged = !_areOrdersEqual(_picked, listForTop); if (hasChanged) { if (mounted) { setState(() { _picked = listForTop; }); debugPrint( '[MYPICKUPS] 🔄 UI Updated: ${listForTop.length} bookings', ); } } else { // No changes } // Get all Booking IDs from the current list final currentOrderIds = sortedList .map((o) => (o['orderid'] ?? '').toString()) .where((id) => id.isNotEmpty) .toSet(); // ✅ Find all ACTIVE pickup and ensure timers are running final activeOrders = sortedList .where((o) => stopStatusOf(o).isActive) .toList(); debugPrint('[MYPICKUPS] Found ${activeOrders.length} active pickup'); // ✅ CRITICAL: Only set has_live_pickup to true if there are ACTIVE bookings (status = "active") // Don't set it to true for "picked" or other statuses - only for "active" try { final hasActiveOrders = activeOrders.isNotEmpty; await prefs.setBool('has_live_pickup', hasActiveOrders); debugPrint( '[MYPICKUPS] Set has_live_pickup: $hasActiveOrders (${activeOrders.length} active bookings)', ); // Clear active_pickup_order_id if no active pickup if (!hasActiveOrders) { final activeOrderId = prefs.getString('active_pickup_order_id'); if (activeOrderId != null && activeOrderId.isNotEmpty) { debugPrint( '[MYPICKUPS] Clearing stale active_pickup_order_id: $activeOrderId (no active pickup)', ); await prefs.remove('active_pickup_order_id'); } } } catch (_) {} // Update active pickup list for banner display if (mounted) { setState(() { _activePickups = activeOrders; }); // Whether a stop is live governs both how fast we poll and whether the // screen is held awake, so they are re-derived wherever it changes. _syncPolling(); _syncWakelock(); } // Get set of active Booking IDs final activeOrderIds = activeOrders .map((o) => (o['orderid'] ?? '').toString()) .where((id) => id.isNotEmpty) .toSet(); // ✅ CRITICAL: Start timers ONLY for active pickup that don't have one yet // This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch for (final Booking in activeOrders) { final orderId = (Booking['orderid'] ?? '').toString(); if (orderId.isEmpty) continue; // ✅ ONLY start timer if it doesn't already exist // This prevents restarting timers on every _fetchPicked() call (which happens frequently) if (!_pickupTimers.containsKey(orderId)) { debugPrint( '[MYPICKUPS] Active pickup: $orderId - Starting pickup log posting (timer not found)', ); // ✅ RE-ENABLED: Pickup logs now posted from pickup page with robust validation await _startPickupPosting(Booking); } else { debugPrint( '[MYPICKUPS] Active pickup: $orderId - Timer already running, skipping restart', ); } _activePickupOrderId = orderId; // Track the active pickup // Persist active pickup ID so it survives app restarts try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('active_pickup_order_id', orderId); } catch (e) { debugPrint('[MYPICKUPS] Error saving active pickup ID: $e'); } } // ✅ Stop timers ONLY for pickup that are no longer active or in the list final timersToStop = _pickupTimers.keys .where( (id) => !activeOrderIds.contains(id) || !currentOrderIds.contains(id), ) .toList(); for (final id in timersToStop) { debugPrint( '[MYPICKUPS] Stopping timer for orderId: $id (no longer active)', ); _stopPickupPosting(id); } debugPrint('[MYPICKUPS] Active timers: ${_pickupTimers.keys.toList()}'); debugPrint('[MYPICKUPS] Active pickup orderId: $_activePickupOrderId'); } catch (e) { debugPrint('[MYPICKUPS] Error fetching picked bookings: $e'); if (mounted) setState(() => _fetchFailed = true); } finally { _fetching = false; if (!_firstLoadDone && mounted) { setState(() => _firstLoadDone = true); } } } // ignore: unused_element Map _createBasePayload(Map it) { return { '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 _startPickupPosting(Map 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()) { final riderLog = Get.find(); 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 = { '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 _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 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 _performPost(String orderId) async { try { debugPrint( '[PICKUPS][PICKUPLOG][POST] 🔄 Starting _performPost for orderId: $orderId', ); Map? 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( context: context, builder: (ctx) => AlertDialog( // Same rule as the app bar above: a meal rider has deliveries // pending, not pickups. `jobNounPlural` is 'deliveries' on that // line and 'bookings' on a parcel route. title: Text('Pending ${ServiceProfile.active.workTabLabel}'), content: Text( 'Are you sure you want to close? There are ' '${ServiceProfile.active.jobNounPlural} pending.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('No'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Yes'), ), ], ), ); if (confirm == true && context.mounted) { Navigator.pop(context); } } else { Navigator.pop(context); } }, child: Scaffold( // The same white as Home, Activity and Account. This was `surface` // (#FCF9F8, a warm off-white); the difference is invisible under a full // list and obvious the moment the list is short, so the tab appeared to // change colour with the rider's workload. // The brand shows through the sheet's two top corners — see // [MilerSheet]. backgroundColor: ColorConstants.primary, appBar: MilerAppBar( // ── The tab and the page it opens must agree ── // // This was the literal 'My Bookings', while the nav tab underneath it // already read the noun off the profile // (`ServiceProfile.active.workTabLabel`, see `Bottom_page.dart`). So a // meal rider tapped a tab called **Deliveries** and arrived on a page // titled **My Bookings** — the one screen where the two words sit a // thumb apart was the one screen that disagreed with itself. // // Same source as the tab now: "My Deliveries" on a meal run, "My // Bookings" on a parcel route. See [ServiceProfile.jobNounPlural]. title: 'My ${ServiceProfile.active.workTabLabel}', trailing: MapViewRow( pickup: _picked, preservedStepNumbers: _preservedStepNumbers, ), ), // ── This screen is cards on a ground; Home is rows on a surface ── // // Deliveries is the one work screen whose content genuinely *is* a // stack of raised objects: the NOW card is a layer-2 command surface, // and UP NEXT and LATER recede beneath it. A layer-2 surface needs a // layer-0 ground to sit on, and this page was giving it white — so the // card that is supposed to dominate the screen was separating from its // page by 1.124 : 1, and had to carry a border and a shadow to be seen // at all. // // Home and Activity keep the working layer, because they have no cards // by design — rows on a spine. Same ladder, different rung, and the // rung is chosen by what the screen actually draws. See // [MilerSurface]. body: MilerSheet( color: MilerSurface.canvas, child: Stack( children: [ // Three states, cross-faded: still asking, nothing to do, and the // queue itself. The middle one used to cover the first, and the // hand-off between any two of them used to be a single frame. SmoothSwap( child: _picked.isEmpty && !_firstLoadDone ? const SkeletonList(key: ValueKey('bookings-loading')) : _picked.isEmpty && _fetchFailed ? ErrorRetry( key: const ValueKey('bookings-error'), onRetry: () => _fetchPicked(force: true), ) : _picked.isEmpty ? SingleChildScrollView( key: const ValueKey('bookings-empty'), physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: MediaQuery.of(context).size.height * 0.72, // ── The artwork is the whole empty state ── // // This was a [MilerEmptyState] drawing a headline and a // body line above the illustration — and the // illustration has both baked into it, in the same // words: "You're all caught up", then the line about // new jobs appearing automatically. Rendered together // the screen said each sentence twice, which reads as a // rendering fault rather than as one message. The panel // is the picture now, at a size worth looking at, and // it keeps its own copy. // // [_fetchDiagLine] stays: it is `kDebugMode`-gated and // folds away entirely in release, so nothing ships // underneath the art. child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.symmetric( horizontal: 16.w, ), child: Image.asset( 'assets/images/caught_up.png', width: 330.w, fit: BoxFit.contain, errorBuilder: (_, __, ___) => Icon( LucideIcons.truck, size: 64.sp, color: ColorConstants.borderStrong, ), ), ), _fetchDiagLine(), ], ), ), ), ) : Column( key: const ValueKey('bookings-list'), children: [ // No trip tabs here. Everything on this screen is already // accepted, so the only live question is which stop is // next — and that has one answer across the whole day. // See [_visibleStops]. _buildTripRail(), Expanded( child: RefreshIndicator( onRefresh: _fetchPicked, color: ColorConstants.primary, // ── An empty ROW list, not an empty stop list ── // // This asked `_visibleStops.isEmpty`, but the rows // are built from a filtered subset of it. When the // stops were all finished-but-not-yet-cleared, the // page took the populated branch and rendered a // `ListView` with zero children: a blank screen // under the rail, with no explanation and nothing // to pull on. Ask the list that is actually drawn. child: _queueRows().isEmpty ? _buildTripTabEmptyState() : Builder( builder: (context) { final rows = _queueRows(); return ListView.builder( padding: EdgeInsets.only( top: 2.h, // Room for the live strip only when // one is actually drawn — see // [_liveOnThisScreen]. // ── Room for whichever bar is // drawn ── // // A flat 80 was the live strip's // height, and the round bar is // taller: a caption, a primary // button and the nav-bar inset // under it. The last card sat // behind it with its controls // unreachable. Measured from the // pieces rather than guessed, so it // cannot drift when either grows. // ── One measured floor ── // // The unreleased branch was built // from its bar's own pieces; the // live branch was a flat `80.h` // that forgot the device inset // entirely, so on a gesture-nav // phone the last row of the run sat // behind the banner *and* behind // the navigation. Both surfaces now // state their own height and the // floor reserves whichever one is // actually showing, plus the inset. bottom: _bottomReservation(context), ), itemCount: rows.length, itemBuilder: (context, index) { final row = rows[index]; if (row.isHeading) { return _QueueHeading( label: row.heading!, count: row.count, // On the first heading only: // it describes the whole // queue, not the section. order: index == 0 ? _orderSource : null, ); } final item = row.stop!; final orderId = (item['orderid'] ?? '') .toString(); // Live metres + speed drive the // distance / ETA on every density; // RouteMetricsHelper picks the unit // and falls back to an urban average // when the GPS reading is unusable. final distanceMeters = RouteMetricsHelper.metersToStop( item, riderLat: _currentLocation ?.latitude, riderLng: _currentLocation ?.longitude, // The leg decides the end: // a delivery row measures // to the DOOR. It measured // to the kitchen, so the // figure grew as the rider // approached the customer. toDrop: MilkRun.workingKind( item, collectedIds: _collectedIds, ).isDelivery, ); void open() => StopDetailSheet.show( context, stop: item, stopNumber: row.displayStep, // The pairing recorded at the // counter — same store as the // card, so the two agree. bag: row.bag, // The carried set — what turns // a raw pickup row into the // delivery leg the sheet must // describe. collectedIds: _collectedIds, riderLat: _currentLocation?.latitude, riderLng: _currentLocation?.longitude, ); // ── One card shape per density ── // // Not one card dimmed four ways. See // [QueueDensity] for why the live stop // is physically bigger rather than // merely brighter. final Widget child = switch (row .density) { QueueDensity.now => PickupCard( key: ValueKey( 'pickup_$orderId', ), item: item, displayStep: row.displayStep, distanceMeters: distanceMeters, riderSpeedMps: _currentLocation?.speed, bag: row.bag, collectedIds: _collectedIds, outForDeliveryIds: _outForDeliveryIds, ), QueueDensity.next => _NextCard( item: item, bag: row.bag, distanceMeters: distanceMeters, riderSpeedMps: _currentLocation?.speed, onOpen: open, ), QueueDensity.later => _LaterRow( item: item, bag: row.bag, distanceMeters: distanceMeters, onOpen: open, ), QueueDensity.skipped => _SkippedRow( item: item, bag: row.bag, reason: row.reason, onOpen: open, onResume: () => _resumeSkipped(item), ), }; return Reveal( key: ValueKey('reveal_$orderId'), delay: staggerDelay(index), child: child, ); }, ); }, ), ), ), ], ), ), // The live strip, shared with Home. It places itself clear of the // floating nav bar, so it is pinned flush here and left to it. // // Only for a stop this screen can actually work. On a kitchen line // a collection is Home's, in bulk, so a strip here offering to // "continue" one was an invitation into a flow the rider is not // supposed to be in — and the way most riders found it. See // [_liveOnThisScreen]. // ── The hinge of the day, at the foot of the tab that holds the // load ── // // It shares this slot with the live strip and yields to it: once a // stop is running, "continue that stop" is the only thing the rider // wants from the bottom of this screen, and a second bar under his // thumb offering to start something else is how the wrong one gets // pressed. See [startRound]. // ── One contextual slot, and the workflow advances through it ── // // These were two `if`s over the same corner. Logically exclusive // already — a live stop suppresses the start bar, because a // second bar under the rider's thumb offering to start something // else is how the wrong one gets pressed — but they *swapped* by // one disappearing and the other appearing in the same frame, // which reads as the screen glitching rather than as **Start // delivery → Continue this stop**. // // One switcher, one child at a time, on the app's own state // duration. Keyed by which surface it is rather than by its // contents, so a poll that returns the same live stop does not // replay the transition. Positioned( left: 0, right: 0, bottom: 0, child: AnimatedSwitcher( duration: DesignConstants.motionState, switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeOutCubic, transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: SlideTransition( position: Tween( begin: const Offset(0, 0.12), end: Offset.zero, ).animate(anim), child: child, ), ), child: _liveOnThisScreen.isNotEmpty ? ActivePickupBanner( key: const ValueKey('live'), activePickups: _liveOnThisScreen, onTap: (pickup) async { await startPickup(pickup); // Refresh after returning from map screen if (mounted) { await Future.delayed( const Duration(milliseconds: 500), ); _fetchPicked(); } }, ) : _unreleased.isNotEmpty ? StartRoundBar( key: const ValueKey('start-round'), count: _unreleased.length, busy: _starting, onPressed: _startWholeRound, ) : const SizedBox.shrink(key: ValueKey('none')), ), ), ], ), ), ), ); } } /// ───────────────────────────────────────────────────────────────────────── /// START DELIVERY — one press, the whole load /// /// The bar the rider hits after the last counter. It states what it is about to /// act on — `5 orders in hand` — because the press releases every one of them, /// and a control acting on a set the rider cannot see is one he learns not to /// trust. /// /// ── Not a slide ── /// /// Slides are reserved for the writes a resting thumb must not be able to make: /// "picked" is the rider asserting he is holding somebody's lunch, "delivered" /// that he has handed it over. Setting off is neither irreversible nor a claim /// about somebody else's property — pressing it early costs a hub board that /// reads *active* a few minutes early — so it is a button, at the size of every /// other primary action in the app. /// /// It sits above the floating nav bar rather than under it: [BottomPage] owns /// that inset, and using it here is what keeps the button clear of the tab /// strip on a tall phone. /// How much scroll extent the queue must leave under itself. /// /// ── Why this is a function and not a number ── /// /// It was two branches and a magic constant: the unreleased case was built /// from its bar's own pieces, and the live case was a flat `80.h` that forgot /// the device inset entirely — so on a gesture-navigation phone the last row of /// the run sat behind the banner *and* behind the navigation, with its controls /// unreachable and no way to scroll to them. /// /// One rule, reading the same two conditions the slot itself reads, so the /// floor and the surface can never disagree about which one is on screen. Each /// surface states its own height, because the thing that overlays a list is the /// only thing that knows how much of the list it covers. double bottomReservationFor({ required double inset, required bool live, required bool unreleased, double idle = 8, }) { if (live) return inset + ActivePickupBanner.maxHeight; if (unreleased) return inset + StartRoundBar.maxHeight; return inset + idle; } class StartRoundBar extends StatelessWidget { /// The tallest this bar gets, excluding the device inset. See /// [ActivePickupBanner.maxHeight] for why the surface declares its own. static double get maxHeight => ButtonSizes.primary + 54.h; final int count; final bool busy; final VoidCallback onPressed; const StartRoundBar({ super.key, required this.count, required this.busy, required this.onPressed, }); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.fromLTRB( 16.w, 12.h, 16.w, BottomPage.bottomInset(context) + 10.h, ), decoration: BoxDecoration( color: ColorConstants.pureSurface, boxShadow: [ // The list scrolls under this, so the edge has to read as an edge — a // shadow rather than a rule, for the same reason the route cards have // one: a hairline is not there on a scratched screen in sun, and a // rule across a scrolling list reads as a divider between two rows. BoxShadow( color: ColorConstants.slateText.withValues(alpha: 0.10), blurRadius: 18, offset: const Offset(0, -4), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$count ${count == 1 ? 'order' : 'orders'} in hand', style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 8.h), MilerButton( label: 'Start delivery', icon: LucideIcons.truck, color: ColorConstants.primary, loading: busy, onPressed: busy ? null : onPressed, ), ], ), ); } }