// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast library; import 'dart:async'; import 'dart:convert'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:miler/views/helpers/constants/design_constants.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:lottie/lottie.dart' hide Marker; 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:slider_button_lite/feature/presentation/slider_button/slider.dart'; import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.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/stop_verify.dart'; 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/views/helpers/constants/apiconstants.dart'; import 'dart:io' show Platform; import 'package:miler/data/api_config.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/stop_compliance.dart'; import 'package:flutter/services.dart'; import 'package:floating/floating.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:http/http.dart' as http; import 'package:scratcher/scratcher.dart'; import 'package:confetti/confetti.dart'; import 'package:miler/views/helpers/constants/miler_type.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 '../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); 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]); stop['orderstatus'] = 'accepted'; dc.triggerRefresh(); return true; } // ✅ 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 = >[]; /// 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 (active/arrived), 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. List _tripProgress() { final states = []; for (final stop in _railStops) { final status = stopStatusOf(stop); if (status.isFinishedPickup) { states.add(StopProgress.done); } else if (status.isSkipped) { states.add(StopProgress.skipped); } else if (status.isActive || status == StopStatus.arrived) { states.add(StopProgress.current); } else { states.add(StopProgress.pending); } } // 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, ]; /// 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( Icons.check_circle_outline_rounded, 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.w800, 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.w600, 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.w600, 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, ); } // ── 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, ); } List> _sortOrders(List> bookings) { if (bookings.isEmpty) return bookings; // Use preserved step numbers for sorting to maintain original Booking final ordersWithStep = bookings .where((o) => _getPreservedOrCurrentStep(o) > 0) .toList(); final ordersWithoutStep = bookings .where((o) => _getPreservedOrCurrentStep(o) == 0) .toList(); ordersWithStep.sort( (a, b) => _getPreservedOrCurrentStep( a, ).compareTo(_getPreservedOrCurrentStep(b)), ); if (_currentLocation != null && ordersWithoutStep.isNotEmpty) { ordersWithoutStep.sort((a, b) { final distA = _calculateDistanceFromCurrent(a); final distB = _calculateDistanceFromCurrent(b); return distA.compareTo(distB); }); } return [...ordersWithStep, ...ordersWithoutStep]; } 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(); _restoreActivePickup(); _initializeLocation(); // Don't block _fetchPicked(); _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'); } } 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; } /// 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, }) { final key = _getOrderKey(order); final orderId = (order['orderid'] ?? '').toString(); _completedOrderKeys.add(key); _stopPickupPosting(orderId); // 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) .then( (_) => debugPrint( '[ACTIVITY] recorded $orderId as ${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]); 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); stamped['orderstatus'] = cancelled ? 'cancelled' : '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; // 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(), ); } 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; 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(); var dedupedList = items.where((o) { final id = (o['orderid'] ?? '').toString(); if (id.isNotEmpty && acceptedIds.contains(id)) return true; final status = (o['orderstatus']?.toString().toLowerCase() ?? '') .trim(); const pending = {'', 'assigned', 'miler_assigned', 'pending', 'new'}; return !pending.contains(status); }).toList(); // ── Debug-only demo fallback ─────────────────────────────────────── // // Gated on the list that actually reaches the UI, not on the raw API // response — which is what was still leaving this tab blank. // // The backend answers a signed-in miler with his ASSIGNED bookings, so // `items` is rarely empty. But assigned work belongs on Home until he // accepts it, and the filter directly above drops exactly that. The // result: a non-empty API response, an empty screen, and a fallback // keyed on `items.isEmpty` that therefore never fired. Checking after // the filter is the only check that matches what the rider sees. // // 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 leave too. A skip means "not now, come back later", // which is not the same as work in hand: left here they sat among the // live stops looking identical to them, and the rider had to remember // which of his eight cards were real. They wait on Home, with the // other undecided work, and resuming one there sends it back to this // list as an ordinary accepted stop. final listForTop = sortedList.where((o) { final st = stopStatusOf(o); return !st.isActive && !st.isFinishedPickup && !st.isSkipped; }).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 = '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( title: const Text('Pending Pickups'), content: const Text( 'Are you sure you want to close? There are pickups 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. backgroundColor: ColorConstants.pureSurface, appBar: MilerAppBar( title: 'My Bookings', trailing: MapViewRow( pickup: _picked, preservedStepNumbers: _preservedStepNumbers, ), ), body: 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, // One empty-state component across the app — see // [MilerEmptyState]. This was hand-rolled with its own // headline size, body size, animation size and padding, // as was Activity's; three near-identical treatments for // the state a new rider sees most in his first week. child: MilerEmptyState( icon: Icons.local_shipping_rounded, illustration: Lottie.asset( 'assets/lotties/all_caught_up.json', fit: BoxFit.contain, repeat: true, errorBuilder: (_, __, ___) => Icon( Icons.local_shipping_rounded, size: 64.sp, color: ColorConstants.borderStrong, ), ), title: "You're all caught up", message: 'New bookings will appear here automatically as ' 'they come in.', accent: ColorConstants.acceptGreen, footer: Column( mainAxisSize: MainAxisSize.min, children: [ _fetchDiagLine(), Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 8.w, height: 8.w, child: CircularProgressIndicator( strokeWidth: 2, color: ColorConstants.acceptGreen, ), ), SizedBox(width: 8.w), Text( 'Listening for new pickups…', style: MilerType.label.on( ColorConstants.acceptGreen, ), ), ], ), ], ), ), ), ) : 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, child: _visibleStops.isEmpty ? _buildTripTabEmptyState() : ListView.builder( padding: EdgeInsets.only( top: 6.h, bottom: _activePickups.isNotEmpty ? 80.h : 8.h, ), itemCount: _visibleStops.length, itemBuilder: (context, index) { final stops = _visibleStops; final item = stops[index]; final int displayStep = _getDisplayStepNumber(stops, index); // Live metres + speed drive the card's distance / // ETA strip; RouteMetricsHelper picks the unit and // falls back to an urban average speed when the GPS // reading is unusable. final double? distanceMeters = RouteMetricsHelper.metersToStop( item, riderLat: _currentLocation?.latitude, riderLng: _currentLocation?.longitude, ); final double? riderSpeedMps = _currentLocation?.speed; final String orderId = (item['orderid'] ?? '').toString(); final StopStatus status = stopStatusOf( item, ); // Logic for enabling cards: // 1. Skipped bookings are ALWAYS enabled (so they can be un-skipped or completed) // 2. The first non-skipped, non-cancelled, non-Picked up Booking is enabled // 3. All other bookings are disabled final bool isSkipped = status.isSkipped; // (Previously compared a lower-cased string to // 'Picked up' — which could never match. Normalized // status fixes that so completed stops truly hide.) final bool isCancelledOrPickups = status.isCancelled || status.isPicked; final bool hasActive = _hasActivePickup(); // Don't show cancelled or Picked up bookings if (isCancelledOrPickups) { return const SizedBox.shrink(); } // Skipped bookings: only enable when there is NO other active pickup if (isSkipped && !isCancelledOrPickups) { return Reveal( key: ValueKey('reveal_$orderId'), delay: staggerDelay(index), child: PickupCard( key: ValueKey('pickup_$orderId'), item: item, displayStep: displayStep, distanceMeters: distanceMeters, riderSpeedMps: riderSpeedMps, enabled: !hasActive, isSkipped: true, ), ); } // Find the first non-skipped Booking to enable int firstEnabledIndex = -1; for (int i = 0; i < stops.length; i++) { final orderStatus = (stops[i]['orderstatus'] ?.toString() .toLowerCase() ?? '') .trim(); if (orderStatus != 'skipped' && orderStatus != 'cancelled' && orderStatus != 'Picked up') { firstEnabledIndex = i; break; } } // Enable if this is the first available non-skipped Booking // AND there is no active pickup running. final bool enabled = !hasActive && firstEnabledIndex >= 0 && index == firstEnabledIndex; return Reveal( key: ValueKey('reveal_$orderId'), delay: staggerDelay(index), child: PickupCard( key: ValueKey('pickup_$orderId'), item: item, displayStep: displayStep, distanceMeters: distanceMeters, riderSpeedMps: riderSpeedMps, enabled: enabled, isSkipped: false, ), ); }, ), ), ), ], ), ), // 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. if (_activePickups.isNotEmpty) Positioned( left: 0, right: 0, bottom: 0, child: ActivePickupBanner( activePickups: _activePickups, onTap: (pickup) async { await startPickup(pickup); // Refresh after returning from map screen if (mounted) { await Future.delayed(const Duration(milliseconds: 500)); _fetchPicked(); } }, ), ), ], ), ), ); } }