Files
2026-08-11 13:16:33 +05:30

2507 lines
97 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast
library;
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<bool> launchPhoneDialer(String phoneNumber) async {
try {
// Sanitize phone number: keep only digits and '+'
final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), '');
if (phone.isEmpty) {
debugPrint(
'[PHONE] Empty phone number after sanitization, skipping dial',
);
return false;
}
final Uri uri = Uri(scheme: 'tel', path: phone);
// Try canLaunchUrl first (works in debug, may fail in release)
bool canLaunch = false;
try {
canLaunch = await canLaunchUrl(uri);
debugPrint('[PHONE] canLaunchUrl result: $canLaunch');
} catch (e) {
debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e');
// Continue anyway - launch might still work
}
// Always attempt to launch, even if canLaunchUrl returned false
// Using LaunchMode.platformDefault is often safer for system intents like dialing
try {
final launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
if (launched) {
debugPrint('[PHONE] Successfully launched dialer for: $phone');
return true;
} else {
debugPrint('[PHONE] launchUrl returned false for: $phone');
}
} catch (e) {
debugPrint('[PHONE] Failed to launch dialer: $e');
}
return false;
} catch (e) {
debugPrint('[PHONE] Error in launchPhoneDialer: $e');
return false;
}
}
class MyPickups extends StatefulWidget {
const MyPickups({super.key});
@override
State<MyPickups> createState() => _MyPickupsState();
/// Forgets a local skip, so the stop stops being re-stamped 'skipped' on the
/// next poll.
///
/// Keyed the way this screen keys everything: pickup id when there is one,
/// order id otherwise.
static void forgetSkip({String pickupId = '', String orderId = ''}) {
final key = pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
_MyPickupsState._forgetSkip(key);
}
/// Puts a skipped stop back into the working queue.
///
/// A skip parks a stop for a return visit, so it waits on **Activity** — the
/// record of what has happened today — rather than among the live work on
/// Bookings. Resuming is an accept on the wire, the same call the accept bar
/// makes, plus clearing the skip in both places that hold one:
///
/// • [_skippedOrdersCache], or the 3s poller would re-stamp 'skipped' onto
/// the stop within seconds and the resume would silently undo itself;
/// • the persistent store, which is what Home and Activity read.
///
/// Optimistic, like every other decision in this app: the local record is
/// written whatever the network says, and the poll catches up. Returns false
/// only when there is no order id to act on.
///
/// Lives here rather than on the screen that shows the control because two
/// screens have now owned that control in turn — Home, then Activity — and
/// the logic moved with it both times, cache-clearing included.
static Future<bool> resumeSkippedStop(Map<String, dynamic> stop) async {
final orderId = (stop['orderid'] ?? '').toString();
if (orderId.isEmpty) return false;
forgetSkip(pickupId: (stop['pickupid'] ?? '').toString(), orderId: orderId);
final dc = Get.isRegistered<PickupsController>()
? Get.find<PickupsController>()
: Get.put(PickupsController(), permanent: true);
String lat = '0';
String lng = '0';
try {
final pos =
await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
),
);
lat = pos.latitude.toStringAsFixed(6);
lng = pos.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[RESUME] No location for $orderId: $e');
}
try {
await dc.updateAcceptedStatus(
pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0,
orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0,
ridersLat: lat,
ridersLng: lng,
);
} catch (e) {
debugPrint('[RESUME] $orderId status call failed: $e');
}
final resumed = Map<String, dynamic>.from(stop)
..['orderstatus'] = 'accepted';
await removeSkippedBookings([orderId]);
await addAcceptedBookings([resumed]);
stop['orderstatus'] = 'accepted';
dc.triggerRefresh();
return true;
}
// ✅ Public static method to navigate to pickup map screen from outside (e.g., home page)
static Future<void> navigateToPickupMap(
BuildContext context,
Map<String, dynamic> pickup,
) async {
await openScreen(
context,
_PickupMapScreen(
pickup: pickup,
parentState: null, // No parent state when called from outside
),
swipeToGoBack: false,
);
}
}
class _MyPickupsState extends State<MyPickups>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
final PickupProvider _provider = PickupProvider();
final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider();
List<Map<String, dynamic>> _picked = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _activePickups =
<Map<String, dynamic>>[]; // Track active pickup for banner
StreamSubscription<void>? _pollerSubscription;
/// What [_syncPolling] last set, so an unchanged interval is a no-op.
Duration? _currentPollInterval;
/// False while the app is backgrounded — see [pollInterval].
bool _pollForeground = true;
/// Whether the rider is on duty. Drives the idle poll rate.
bool _onDuty = true;
/// Mirrors the wakelock so it is only toggled on a real change.
bool _wakelockHeld = false;
/// Set when a fetch fails with nothing to fall back on. An empty queue after
/// a network error used to render as the "You're all caught up" celebration —
/// the app congratulating the rider on work it had failed to ask for.
bool _fetchFailed = false;
bool _fetching = false;
/// False until the first fetch has come back, win or lose.
///
/// Without it this screen answered "how many bookings do I have?" before it
/// had asked: an empty `_picked` renders the "You're all caught up" Lottie,
/// so every cold open of the Bookings tab congratulated the rider on an empty
/// queue for as long as the network took, then replaced it with a list of
/// stops. Empty and not-yet-known are different states and only one of them
/// is good news.
bool _firstLoadDone = false;
Position? _currentLocation;
final Map<String, Timer> _pickupTimers = <String, Timer>{};
final Map<String, Map<String, dynamic>> _pickupBasePayload =
<String, Map<String, dynamic>>{};
// Preserve original step numbers so they don't change when pickup are completed
final Map<String, int> _preservedStepNumbers = <String, int>{};
String? _activePickupOrderId;
// Order keys the rider just finished (picked up / cancelled). We keep them
// here so the 3s poller can't resurrect a completed stop before the backend
// reflects it — otherwise a done pickup keeps re-appearing as "active" and
// blocks the next one ("Complete current stop first").
final Set<String> _completedOrderKeys = <String>{};
// Cache for skipped bookings.
//
// Static, and reachable from outside this State, because a skip is now
// visible on two screens: Bookings records it, and Home lists the skipped
// stop until the rider resumes it. Resuming happens on Home — so Home has to
// be able to clear the entry, or this cache would re-stamp 'skipped' onto the
// stop on the very next poll and the resume would silently undo itself. The
// Bookings tab lives inside an IndexedStack and is never disposed, so a
// per-State map really would outlive the resume.
static final Map<String, String> _skippedOrdersCache = {};
static final Map<String, int> _skippedOrderTimestamps = {};
static void _forgetSkip(String key) {
_skippedOrdersCache.remove(key);
_skippedOrderTimestamps.remove(key);
}
Future<void> _saveSkippedOrdersCache() async {
// Implementation can be empty if we rely on API now,
// or strictly local. For now, we'll keep it simple or empty
// to satisfy the interface expected by child widgets.
// If child widgets call this, they expect it to exist.
}
@override
bool get wantKeepAlive => true;
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
const double R = 6371.0;
final double dLat = _toRadians(lat2 - lat1);
final double dLon = _toRadians(lon2 - lon1);
final double a =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_toRadians(lat1)) *
math.cos(_toRadians(lat2)) *
math.sin(dLon / 2) *
math.sin(dLon / 2);
final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return R * c;
}
double _toRadians(double degrees) {
return degrees * math.pi / 180.0;
}
int _getStepNumber(Map<String, dynamic> Booking) {
final dynamic raw = Booking['step'] ?? Booking['Step'];
final int step = raw == null
? 0
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
return step;
}
String _getOrderKey(Map<String, dynamic> Booking) {
// Use pickupId as primary key, fallback to orderId
final pickupId = (Booking['pickupid'] ?? '').toString();
final orderId = (Booking['orderid'] ?? '').toString();
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
}
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
final orderKey = _getOrderKey(Booking);
// If we have a preserved step number, use it; otherwise use current API step
if (_preservedStepNumbers.containsKey(orderKey)) {
return _preservedStepNumbers[orderKey]!;
}
final currentStep = _getStepNumber(Booking);
// Preserve the step number if it's valid (> 0)
if (currentStep > 0) {
_preservedStepNumbers[orderKey] = currentStep;
}
return currentStep;
}
/// Today's finished stops, newest last, for the rail.
///
/// Refreshed with every fetch. See [_railStops] for why the rail needs them.
List<Map<String, dynamic>> _completedToday = <Map<String, dynamic>>[];
/// What the rail draws: the whole run, finished stops included.
///
/// ── Why this is not [_visibleStops] ──
///
/// It was, and that is why the rail never moved. `_visibleStops` is the list
/// of stops still to *work*, and a completed stop is removed from it the
/// instant it is confirmed — so the rail was handed a set in which nothing
/// could ever be `done`. Every node stayed grey, the scooter sat on the first
/// one all day, and finishing a stop made the rail one node SHORTER instead
/// of one node greener: the rider's only measure of progress went backwards
/// as he made it.
///
/// The completed stops come from the same store the Activity tab reads, so
/// the two agree about what was finished, and they lead because they were
/// done first. The open ones follow in the hub's order.
List<Map<String, dynamic>> get _railStops {
final open = _visibleStops;
if (_completedToday.isEmpty) return open;
final openKeys = open.map(_getOrderKey).toSet();
final done = _completedToday
.where((o) => !openKeys.contains(_getOrderKey(o)))
.toList()
.reversed // the store hands them back newest first
.toList();
return [...done, ...open];
}
/// Progress state for every stop on the accepted trip, in the admin's fixed
/// order — what drives the red→green rail at the top of Bookings.
///
/// Exactly one stop is ever [StopProgress.current]: whichever the rider is
/// physically on (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<StopProgress> _tripProgress() {
final states = <StopProgress>[];
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<String, int> _tripNumberByOrderId = const {};
/// Groups every stop the app knows about today and hands back order id → trip
/// number. Deduped by order id first: the same stop arrives from up to three
/// sources per fetch, and counting it twice would not change its trip but
/// would waste the grouping's arithmetic on copies.
Map<String, int> _numberTrips(List<Map<String, dynamic>> allToday) {
final byId = <String, Map<String, dynamic>>{};
for (final s in allToday) {
final id = (s['orderid'] ?? '').toString();
if (id.isEmpty) continue;
byId.putIfAbsent(id, () => s);
}
if (byId.isEmpty) return const {};
final trips = Trip.groupIntoTrips(
byId.values.toList(),
hubLat: _currentLocation?.latitude,
hubLng: _currentLocation?.longitude,
);
return <String, int>{
for (var i = 0; i < trips.length; i++)
for (final id in trips[i].orderIds) id: i + 1,
};
}
/// The trip a stop belongs to, or null when the day's shape is not known yet.
int? _tripNumberOf(Map<String, dynamic> stop) =>
_tripNumberByOrderId[(stop['orderid'] ?? '').toString()];
/// The trip the rider is riding right now: the one owning the stop he is on,
/// or the next one he will reach. Null when nothing is outstanding.
int? get _currentTripNumber {
final stops = _visibleStops;
if (stops.isEmpty) return null;
// The live stop first — it is the one he is standing at.
for (final s in stops) {
if (stopStatusOf(s).isActive) {
final n = _tripNumberOf(s);
if (n != null) return n;
}
}
for (final s in stops) {
final n = _tripNumberOf(s);
if (n != null) return n;
}
return null;
}
/// Every trip number present in the outstanding queue, in order. More than one
/// means the rider is carrying work from two slots at once, which he is
/// entitled to know before he plans his afternoon.
List<int> get _queuedTripNumbers {
final seen = <int>{};
for (final s in _visibleStops) {
final n = _tripNumberOf(s);
if (n != null) seen.add(n);
}
final list = seen.toList()..sort();
return list;
}
/// The accepted stops, grouped into the same trips Home shows.
///
/// Still grouped, because grouping is what puts the stops in the hub's
/// intended riding order. Bookings no longer *shows* the grouping — see
/// [_visibleStops].
List<Trip> get _bookingTrips => Trip.groupIntoTrips(
_picked,
hubLat: _currentLocation?.latitude,
hubLng: _currentLocation?.longitude,
);
/// Every accepted stop, in riding order, as one continuous list.
///
/// ── Why the Trip 1 · 2 · 3 tabs are gone from this screen ──
///
/// They were here to mirror Home, and mirroring was the wrong goal. The two
/// screens answer different questions:
///
/// • **Home is a decision screen.** The rider is choosing which of the day's
/// three trips to take, so the trips have to be separable — he accepts one
/// slot's route and declines another's. Tabs are right there.
///
/// • **Bookings is a work screen.** Everything on it is already accepted;
/// the only question left is *which stop do I ride to next*. That question
/// has one answer across the whole day, and it does not care which slot the
/// stop was assigned in.
///
/// The tabs actively got in the way of it. Finishing the last stop of Trip 1
/// left the rider on a tab reading "Trip 1 is done" with his next stop hidden
/// behind a tap he had no reason to know he needed — the screen looked
/// finished while work remained. And a stop could be *invisible* on the screen
/// whose entire job is to show him his work, purely because a tab was closed.
///
/// One list, in order, no hidden state.
List<Map<String, dynamic>> get _visibleStops => [
for (final trip in _bookingTrips) ...trip.stops,
];
/// 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<String, dynamic> m) {
final String apiKmsStr = (m['kms'] ?? '').toString().trim();
final double apiKms = double.tryParse(apiKmsStr) ?? 0.0;
if (apiKms > 0) {
return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0);
}
final double rLat = _parseD(m['riderslat']);
final double rLon = _parseD(m['riderslon']);
final double dLat = _parseD(m['droplat'] ?? m['pickuplat']);
final double dLon = _parseD(m['droplon'] ?? m['pickuplong']);
if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) {
final double km = _haversineKm(rLat, rLon, dLat, dLon);
return km.toStringAsFixed(km < 10 ? 1 : 0);
}
final double pLat = _parseD(m['pickuplat']);
final double pLon = _parseD(m['pickuplon']);
if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) {
final double km = _haversineKm(pLat, pLon, dLat, dLon);
return km.toStringAsFixed(km < 10 ? 1 : 0);
}
return '0';
}
double _calculateDistanceFromCurrent(Map<String, dynamic> Booking) {
if (_currentLocation == null) return double.infinity;
final double dropLat = _parseD(Booking['droplat'] ?? Booking['pickuplat']);
final double dropLon = _parseD(Booking['droplon'] ?? Booking['pickuplong']);
if (dropLat == 0 || dropLon == 0) return double.infinity;
return _haversineKm(
_currentLocation!.latitude,
_currentLocation!.longitude,
dropLat,
dropLon,
);
}
List<Map<String, dynamic>> _sortOrders(List<Map<String, dynamic>> 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<Map<String, dynamic>> allOrders, int index) {
final Booking = allOrders[index];
// Use preserved step number if available, otherwise get current step
final step = _getPreservedOrCurrentStep(Booking);
if (step > 0) return step;
// For bookings without step numbers, calculate based on bookings that have step numbers
final ordersWithStep = allOrders
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
return ordersWithStep + (index - ordersWithStep) + 1;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadDutyState();
// Listen for external refresh triggers
try {
final dc = Get.isRegistered<PickupsController>()
? Get.find<PickupsController>()
: Get.put(PickupsController());
ever(dc.refreshTrigger, (_) {
if (mounted) {
debugPrint(
'[MYPICKUPS] External trigger -> Refreshing picked bookings',
);
_fetchPicked(force: true);
}
});
} catch (_) {}
// ── Re-read whenever this tab comes forward ──
//
// The shell keeps all four tabs alive in an `IndexedStack`, so arriving
// here is not a build — it is an index change, and nothing about it asks
// this screen for fresh data. The poller does eventually, up to three
// seconds later, which is exactly long enough for a rider who has just
// accepted a stop on Home to arrive, find it missing, and conclude the
// accept failed. Activity carries the same listener for the same reason.
BottomPage.currentTab.addListener(_onTabChanged);
// Run these in parallel
_restoreSkips();
_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<void> _restoreSkips() async {
try {
final skipped = await getSkippedBookings();
for (final b in skipped) {
final key = _getOrderKey(b);
_skippedOrdersCache[key] = (b['skipreason'] ?? '').toString();
_skippedOrderTimestamps[key] =
DateTime.tryParse(
(b['skippedat'] ?? '').toString(),
)?.millisecondsSinceEpoch ??
DateTime.now().millisecondsSinceEpoch;
}
if (skipped.isNotEmpty) {
debugPrint('[MYPICKUPS] Restored ${skipped.length} skip(s) from store');
}
} catch (e) {
debugPrint('[MYPICKUPS] Could not restore skips: $e');
}
}
Future<void> _restoreActivePickup() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedActiveId = prefs.getString('active_pickup_order_id');
if (savedActiveId != null && savedActiveId.isNotEmpty) {
_activePickupOrderId = savedActiveId;
debugPrint(
'[MYPICKUPS] Restored active pickup orderId: $savedActiveId',
);
}
} catch (e) {
debugPrint('[MYPICKUPS] Error restoring active pickup: $e');
}
}
/// Restarts the poller at the speed the rider's current situation deserves.
///
/// Cheap to call: if the interval has not changed, the running stream is left
/// alone rather than torn down and rebuilt (which would reset the clock and,
/// called from a fetch handler, could starve the poll entirely).
void _syncPolling() {
if (!_pollForeground) {
_pollerSubscription?.cancel();
_pollerSubscription = null;
_currentPollInterval = null;
return;
}
final Duration interval = pollInterval(
hasLiveWork: _activePickups.isNotEmpty,
onDuty: _onDuty,
);
if (_pollerSubscription != null && _currentPollInterval == interval) return;
_pollerSubscription?.cancel();
_currentPollInterval = interval;
_pollerSubscription = Stream.periodic(interval, (_) {})
.asyncMap((_) async {
if (mounted && !_fetching) {
await _fetchPicked();
}
})
.listen(
(_) {},
onError: (error) {
debugPrint('[MYPICKUPS][STREAM ERROR] $error');
},
cancelOnError: false,
);
}
void _startPollingStream() => _syncPolling();
/// The screen stays awake only while a stop is actually live.
///
/// It used to be held for the whole time Bookings was open — so a rider with
/// the phone in his pocket and nothing running had a screen that would not
/// sleep, which is the fastest way to flatten a battery mid-shift. Worse, it
/// was held *here*, on the list, and not on the map screen where he is
/// actually navigating and genuinely needs it.
void _syncWakelock() {
final bool keepAwake = _activePickups.isNotEmpty;
if (keepAwake == _wakelockHeld) return;
_wakelockHeld = keepAwake;
if (keepAwake) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
final bool foreground = state == AppLifecycleState.resumed;
if (foreground == _pollForeground) return;
_pollForeground = foreground;
if (foreground) {
// One catch-up fetch immediately, so he never comes back to a queue that
// was true a minute ago, then the normal cadence.
_fetchPicked(force: true);
_syncPolling();
} else {
_syncPolling();
// Nothing is being watched, so nothing needs the screen.
if (_wakelockHeld) {
_wakelockHeld = false;
WakelockPlus.disable();
}
}
}
/// Duty state decides the idle poll rate. Read from the same pref the duty
/// toggle writes, so Bookings and Home agree without a second source.
Future<void> _loadDutyState() async {
try {
final prefs = await SharedPreferences.getInstance();
final int onduty = prefs.getInt('onduty') ?? 1;
if (!mounted) return;
final bool next = onduty != 0;
if (next != _onDuty) {
setState(() => _onDuty = next);
_syncPolling();
}
} catch (_) {
// Duty is unreadable — assume on duty, which polls more often rather
// than less. Being wrong in the direction of freshness is the safe way
// to be wrong about whether a rider is working.
}
}
Future<void> _initializeLocation() async {
try {
// 1. Try Last Known Position (Instant & Preferred)
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
_currentLocation = lastPos;
});
// If we have a cached location, don't block waiting for fresh GPS
// We can let the background stream update it later
return;
}
} catch (_) {}
// 2. Try Current Position (Optimized)
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return;
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever ||
permission == LocationPermission.denied) {
return;
}
// Single attempt with balanced accuracy/timeout
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 3),
),
);
if (mounted) {
setState(() => _currentLocation = position);
}
} catch (_) {
// Fallback to low accuracy if high fails
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 2),
),
);
if (mounted) {
setState(() => _currentLocation = position);
}
} catch (_) {}
}
} catch (e) {
debugPrint('[MYPICKUPS] Error initializing location: $e');
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
BottomPage.currentTab.removeListener(_onTabChanged);
_pollerSubscription?.cancel();
for (final timer in _pickupTimers.values) {
timer.cancel();
}
_pickupTimers.clear();
_pickupBasePayload.clear();
// Persist current live pickup state for other screens
SharedPreferences.getInstance()
.then((p) => p.setBool('has_live_pickup', _picked.isNotEmpty))
// ignore: body_might_complete_normally_catch_error
.catchError((_) {});
if (_wakelockHeld) WakelockPlus.disable();
super.dispose();
}
bool _shallowMapEquals(Map<String, dynamic> a, Map<String, dynamic> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (final entry in a.entries) {
if (b[entry.key] != entry.value) {
return false;
}
}
return true;
}
// Helper to check if two lists of bookings are effectively equal
// This prevents unnecessary rebuilds when polling
bool _areOrdersEqual(
List<Map<String, dynamic>> oldList,
List<Map<String, dynamic>> newList,
) {
if (oldList.length != newList.length) return false;
for (int i = 0; i < oldList.length; i++) {
final oldItem = oldList[i];
final newItem = newList[i];
// Compare critical fields that affect UI or logic
if (oldItem['orderid'] != newItem['orderid']) return false;
if (oldItem['orderstatus'] != newItem['orderstatus']) return false;
if (oldItem['step'] != newItem['step']) return false;
// Compare location data (important for map updates)
if (oldItem['riderslat'] != newItem['riderslat']) return false;
if (oldItem['riderslon'] != newItem['riderslon']) return false;
if (oldItem['pickuplat'] != newItem['pickuplat']) return false;
if (oldItem['pickuplong'] != newItem['pickuplong']) return false;
// Compare notes/instructions if they might change
if (oldItem['notes'] != newItem['notes']) return false;
}
return true;
}
// Manually mark skipped to trigger immediate refresh
void markOrderAsSkipped(Map<String, dynamic> Booking, String reason) {
if (!mounted) return;
final key = _getOrderKey(Booking);
// Record the skip locally so it sticks even if the backend hasn't recorded
// it yet (e.g. mock/offline data). _fetchPicked re-applies this cache on
// every poll so the status can't be reverted.
_skippedOrdersCache[key] = reason;
_skippedOrderTimestamps[key] = DateTime.now().millisecondsSinceEpoch;
// Immediately reflect the skipped status so the card updates and the next
// stop unlocks without waiting on the (possibly failing) API round-trip.
setState(() {
for (final o in _picked) {
if (_getOrderKey(o) == key) {
o['orderstatus'] = 'skipped';
}
}
});
debugPrint('[MYPICKUPS] Marked skipped locally -> $key ("$reason")');
_fetchPicked(force: true);
}
// ✅ Check if there's an active pickup (blocks all actions)
bool _hasActivePickup() {
return _activePickups.isNotEmpty;
}
/// Marks a stop as finished (picked up or cancelled) in local state right
/// away, so the UI unblocks without waiting for the backend/poll to catch up:
/// it leaves the active banner, drops out of the top list, and no longer
/// blocks the next stop.
void markPickupFinished(
Map<String, dynamic> order, {
bool cancelled = false,
}) {
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<String, dynamic>.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<Map<String, dynamic>> remainingPickups() {
return _picked.where((o) {
return !stopStatusOf(o).isFinishedPickup &&
!_completedOrderKeys.contains(_getOrderKey(o));
}).toList();
}
// Previously showed a blocking dialog; now we just disable conflicting buttons in the UI.
void _showActivePickupBlockMessage() {}
/// A rider must be online (on duty) to start/continue a pickup or delivery.
/// Returns true if online; otherwise shows a prompt and returns false.
Future<bool> ensureOnlineForAction() async {
// Single source of truth — same resolution the Home screen uses.
final bool online = await DutyController.to.load();
if (!online && mounted) {
AppFeedback.info(context, "You're offline. Go online from Home to start pickups.");
}
return online;
}
Future<void> startPickup(Map<String, dynamic> item) async {
if (!mounted) return;
// 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<void> resumePickup(Map<String, dynamic> item) async {
// ✅ BLOCK: Check if there's already an active pickup (and this isn't it)
final currentOrderId = (item['orderid'] ?? '').toString();
final activeOrderIds = _activePickups
.map((d) => (d['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// Allow if this IS the active pickup, block if there's a different active pickup
if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) {
// Ignore resume taps when some other pickup is active.
return;
}
// For now, resume behaves same as start (navigates to nav screen)
// You can add specific resume logic here if needed (e.g. un-skip)
await startPickup(item);
}
Future<void> _fetchPicked({bool force = false}) async {
if (_fetching) {
if (force) {
_fetching = false;
} else {
return;
}
}
_fetching = true;
try {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
// ✅ CRITICAL: Fetch from v3 API (picked bookings) AND v2 API (all statuses) IN PARALLEL
// Define V1 fetcher function (current/in-progress bookings)
Future<List<dynamic>> fetchV1() async {
try {
return await _provider.getCurrentPickups();
} catch (e) {
debugPrint('[MYPICKUPS] Error fetching from v1 API: $e');
}
return [];
}
// Execute in parallel with individual error handling
Future<List<dynamic>> fetchV3() async {
try {
return await _provider.getPickupQueuesPicked();
} catch (e) {
debugPrint('[MYPICKUPS] V3 API error: $e');
// Return empty and let the single demo fallback below decide — two
// competing mock paths is how the old one ended up unreachable.
return <dynamic>[];
}
}
final results = await Future.wait([fetchV3(), fetchV1()]);
final itemsV3 = results[0] as List<dynamic>;
final itemsV1 = results[1] as List<dynamic>;
// Merge v3 bookings with v1 bookings (current/in-progress)
final Map<String, Map<String, dynamic>> mergedOrders = {};
// First add v3 bookings (picked/pickuped)
for (final Booking in itemsV3.whereType<Map<String, dynamic>>()) {
final key = _getOrderKey(Booking);
mergedOrders[key] = Booking;
}
// Then add/update with v1 bookings - V1 STATUS TAKES PRECEDENCE
for (final Booking in itemsV1.whereType<Map<String, dynamic>>()) {
final key = _getOrderKey(Booking);
final existing = mergedOrders[key];
if (existing == null) {
mergedOrders[key] = Booking;
} else {
// If it exists in V3 (picked) but V1 says something else, trust V1.
// Especially for 'skipped', 'Picked up', 'cancelled'.
mergedOrders[key] = {
...existing,
...Booking, // Overwrite with V1 data
};
}
}
final items = mergedOrders.values.toList();
debugPrint('[MYPICKUPS] Merged items count: ${items.length}');
// Today's finished stops, for the rail. Read on every fetch rather than
// held from the last completion, so the rail is right after a restart
// mid-shift too.
_completedToday = await getCompletedBookings();
// Merge in bookings accepted from Home (offline/mock accept flow) so the
// Bookings tab count grows as the rider accepts.
final acceptedBookings = await getAcceptedBookings();
if (acceptedBookings.isNotEmpty) {
final existingKeys = items.map(_getOrderKey).toSet();
int added = 0;
for (final Booking in acceptedBookings) {
if (existingKeys.add(_getOrderKey(Booking))) {
items.add(Booking);
added++;
}
}
debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home');
}
// ── The day's shape, before any of this screen's filtering ──
//
// Everything today: what is assigned, what he took, what he finished.
// This is the only point in the fetch where all three are in one list,
// and it is what makes "Trip 2" mean the same thing here as it does on
// Home. See [_tripNumberByOrderId].
_tripNumberByOrderId = _numberTrips([...items, ..._completedToday]);
// Admin-assigned bookings the rider hasn't accepted yet belong on the
// Home tab, not here. Keep only bookings the rider has actually taken on:
// those locally recorded as accepted, or already in a post-accept backend
// state. Anything still pending/assigned is dropped from Bookings.
final acceptedIds = await getAcceptedOrderIds();
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<String, dynamic> _createBasePayload(Map<String, dynamic> it) {
return <String, dynamic>{
'logid': 0,
'tenantid': it['tenantid'] ?? 0,
'partnerid': it['partnerid'] ?? 0,
'locationid': it['locationid'] ?? 0,
'orderheaderid': it['orderheaderid'] ?? 0,
'pickupid': it['pickupid'] ?? 0,
'userid': it['userid'] ?? 0,
'orderid': (it['orderid'] ?? '').toString(),
'orderstatus': 'active',
};
}
// Stop posting logs for a specific pickup
void _stopPickupPosting(String orderId) {
final timer = _pickupTimers.remove(orderId);
if (timer != null) {
timer.cancel();
debugPrint('[PICKUPLOG] Stopped posting logs for orderId: $orderId');
} else {
debugPrint('[PICKUPLOG] No timer to stop for orderId: $orderId');
}
}
Future<(String lat, String lng)?> _getValidCoordinates({
int retryCount = 0,
}) async {
const maxRetries = 3;
try {
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint(
'[PICKUPS][COORDS] Location service disabled, trying last known position',
);
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null &&
lastPos.latitude != 0 &&
lastPos.longitude != 0) {
final lat = lastPos.latitude.toString();
final lng = lastPos.longitude.toString();
debugPrint(
'[PICKUPS][COORDS] ✅ Using last known position: $lat, $lng',
);
return (lat, lng);
}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever ||
permission == LocationPermission.denied) {
debugPrint(
'[PICKUPS][COORDS] Permission denied, trying last known position',
);
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null &&
lastPos.latitude != 0 &&
lastPos.longitude != 0) {
final lat = lastPos.latitude.toString();
final lng = lastPos.longitude.toString();
debugPrint(
'[PICKUPS][COORDS] ✅ Using last known position: $lat, $lng',
);
return (lat, lng);
}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
Position? position;
try {
// Try to get current position with higher accuracy
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy:
LocationAccuracy.high, // Changed to high for better accuracy
timeLimit: Duration(seconds: 8), // Increased timeout
),
).timeout(const Duration(seconds: 8));
} catch (e) {
debugPrint(
'[PICKUPS][COORDS] Timeout getting current position: $e, trying last known',
);
position = await Geolocator.getLastKnownPosition();
}
if (position != null &&
position.latitude != 0 &&
position.longitude != 0) {
final lat = position.latitude.toString();
final lng = position.longitude.toString();
// Validate coordinates are within valid GPS ranges
final latDouble = double.tryParse(lat) ?? 0.0;
final lngDouble = double.tryParse(lng) ?? 0.0;
if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('last_lat', lat);
await prefs.setString('last_lng', lng);
} catch (_) {}
debugPrint('[PICKUPS][COORDS] ✅ Got valid coordinates: $lat, $lng');
return (lat, lng);
} else {
debugPrint(
'[PICKUPS][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng',
);
}
}
// Fallback to SharedPreferences cached coordinates
try {
final prefs = await SharedPreferences.getInstance();
final lat = (prefs.getString('last_lat') ?? '').trim();
final lng = (prefs.getString('last_lng') ?? '').trim();
if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') {
final latDouble = double.tryParse(lat) ?? 0.0;
final lngDouble = double.tryParse(lng) ?? 0.0;
if (latDouble != 0 &&
lngDouble != 0 &&
latDouble.abs() <= 90 &&
lngDouble.abs() <= 180) {
debugPrint(
'[PICKUPS][COORDS] ✅ Using cached coordinates: $lat, $lng',
);
return (lat, lng);
}
}
} catch (_) {}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
debugPrint(
'[PICKUPS][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates',
);
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
debugPrint(
'[PICKUPS][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries',
);
return null;
} catch (e) {
debugPrint('[PICKUPS][COORDS] ❌ Error getting coordinates: $e');
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
}
Future<void> _startPickupPosting(Map<String, dynamic> Booking) async {
final orderId = (Booking['orderid'] ?? '').toString();
if (orderId.isEmpty) {
debugPrint('[PICKUPS][PICKUPLOG] ⚠️ Cannot start timer: empty orderId');
return;
}
// Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above)
if (_pickupTimers.containsKey(orderId)) {
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Timer already exists for $orderId, canceling old one',
);
_pickupTimers[orderId]?.cancel();
_pickupTimers.remove(orderId);
}
debugPrint(
'[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId',
);
debugPrint(
'[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId',
);
// REMOVED: Do not reset cumulative distance here.
// It is already reset in PickupsController.updateActiveStatus when the status actually changes.
// Resetting here causes data loss if the app is restarted while a pickup is in progress.
// START FOREGROUND SERVICE PROTECTION
// Ensure the RiderLogController knows we are active so the Foreground Service stays alive
// This protects THIS timer from being killed by the OS
try {
if (Get.isRegistered<RiderLogController>()) {
final riderLog = Get.find<RiderLogController>();
debugPrint(
'[PICKUPS][PICKUPLOG] 🛡️ Activating Foreground Service via RiderLogController...',
);
// Force 30s interval to match pickup logging
riderLog.startAutoCreateLoginLoop(seconds: 30);
}
} catch (e) {
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Could not start Foreground Service: $e',
);
}
final pickupId = (Booking['pickupid'] ?? 0).toString();
// Get starttime from SharedPreferences (saved when Booking became active via updateActiveStatus)
// If not found, use activetime from Booking data, or current time as fallback
String startTime = '';
try {
final prefs = await SharedPreferences.getInstance();
// Method 1: Get from SharedPreferences (saved when Booking became active)
startTime = prefs.getString('pickup_starttime_$pickupId') ?? '';
if (startTime.isNotEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from SharedPreferences: $startTime',
);
}
// Method 2: Fallback - try to get from Booking data (starttime field)
if (startTime.isEmpty) {
startTime = (Booking['starttime'] ?? Booking['startTime'] ?? '')
.toString();
if (startTime.isNotEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from Booking data: $startTime',
);
}
}
// Method 3: Fallback - try activetime from Booking data
if (startTime.isEmpty) {
final activetime = (Booking['activetime'] ?? Booking['activTime'] ?? '')
.toString();
if (activetime.isNotEmpty) {
startTime = activetime;
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from activetime: $startTime',
);
}
}
// Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called)
if (startTime.isEmpty) {
final now = DateTime.now();
startTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Using current time as starttime fallback: $startTime',
);
}
} catch (e) {
debugPrint('[PICKUPS][PICKUPLOG] ❌ Error getting starttime: $e');
// Set a fallback starttime even on error
final now = DateTime.now();
startTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
}
// CRITICAL: Ensure starttime is never empty
if (startTime.isEmpty) {
final now = DateTime.now();
startTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
debugPrint(
'[PICKUPS][PICKUPLOG] ⚠️ Final fallback: starttime was empty, using: $startTime',
);
}
debugPrint(
'[PICKUPS][PICKUPLOG] 📝 Final starttime for orderId $orderId: $startTime',
);
// Create base payload with starttime
final base = <String, dynamic>{
'logid': 0,
'tenantid': Booking['tenantid'] ?? 0,
'partnerid': Booking['partnerid'] ?? 0,
'locationid': Booking['locationid'] ?? 0,
'orderheaderid': Booking['orderheaderid'] ?? 0,
'pickupid': Booking['pickupid'] ?? 0,
'userid': Booking['userid'] ?? 0,
'orderid': orderId,
'orderstatus': 'active',
'starttime': startTime, // Include starttime in base payload
};
_pickupBasePayload[orderId] = base;
// Save to SharedPreferences for persistence
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'pickuplog_${orderId}_tenantid',
(base['tenantid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_partnerid',
(base['partnerid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_locationid',
(base['locationid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_orderheaderid',
(base['orderheaderid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_pickupid',
(base['pickupid'] ?? 0).toString(),
);
await prefs.setString(
'pickuplog_${orderId}_userid',
(base['userid'] ?? 0).toString(),
);
await prefs.setString('pickuplog_${orderId}_orderid', orderId);
await prefs.setString('pickuplog_${orderId}_orderstatus', 'active');
await prefs.setString(
'pickuplog_${orderId}_starttime',
startTime,
); // Save starttime
} catch (e) {
debugPrint('[PICKUPS][PICKUPLOG] Error saving payload: $e');
}
// Post once immediately (don't await - let it run in background)
_postPickupLog(orderId, Booking);
debugPrint(
'[PICKUPS][PICKUPLOG] 📤 Posted initial log for orderId: $orderId',
);
// Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds
final timer = Timer.periodic(const Duration(seconds: 30), (t) {
debugPrint(
'[PICKUPS][PICKUPLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)',
);
_postPickupLog(orderId, Booking);
});
_pickupTimers[orderId] = timer;
debugPrint(
'[PICKUPS][PICKUPLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)',
);
}
// Reset cumulative distance when Booking becomes active
Future<void> _resetCumulativeDistance(String pickupId) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('pickup_tracking_${pickupId}_lastLat');
await prefs.remove('pickup_tracking_${pickupId}_lastLng');
await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm');
debugPrint(
'[PICKUPS] 🧹 Reset cumulative distance tracking for pickupId: $pickupId',
);
} catch (e) {
debugPrint('[PICKUPS] Error resetting cumulative distance: $e');
}
}
void _postPickupLog(String orderId, Map<String, dynamic> Booking) {
if (!mounted) {
debugPrint('[PICKUPS][PICKUPLOG][POST] Widget disposed, skipping');
return;
}
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}',
);
// Use Future.microtask to ensure the async operation runs independently
Future.microtask(() => _performPost(orderId));
}
Future<void> _performPost(String orderId) async {
try {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] 🔄 Starting _performPost for orderId: $orderId',
);
Map<String, dynamic>? base = _pickupBasePayload[orderId];
if (base == null) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] Base is null, loading from SharedPreferences',
);
try {
final prefs = await SharedPreferences.getInstance();
if (!mounted) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] Widget unmounted after prefs load',
);
return;
}
base = {
'logid': 0,
'tenantid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_tenantid') ?? '0',
) ??
0,
'partnerid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_partnerid') ?? '0',
) ??
0,
'locationid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_locationid') ?? '0',
) ??
0,
'orderheaderid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0',
) ??
0,
'pickupid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_pickupid') ?? '0',
) ??
0,
'userid':
int.tryParse(
prefs.getString('pickuplog_${orderId}_userid') ?? '0',
) ??
0,
'orderid':
prefs.getString('pickuplog_${orderId}_orderid') ?? orderId,
'orderstatus':
prefs.getString('pickuplog_${orderId}_orderstatus') ?? 'active',
'starttime':
prefs.getString('pickuplog_${orderId}_starttime') ??
'', // Load starttime
};
debugPrint(
'[PICKUPS][PICKUPLOG][POST] Base loaded from prefs: $base',
);
} catch (e) {
debugPrint('[PICKUPS][PICKUPLOG][POST] Error loading base: $e');
return;
}
}
// At this point, base is guaranteed to be non-null (either from cache or created above)
final basePayload = base; // Flow analysis ensures base is non-null here
debugPrint('[PICKUPS][PICKUPLOG][POST] Getting coordinates...');
// CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates
final coords = await _getValidCoordinates().timeout(
const Duration(seconds: 10), // Increased timeout to allow retries
onTimeout: () {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ Coordinate timeout after retries',
);
return null;
},
);
if (!mounted) {
debugPrint('[PICKUPS][PICKUPLOG][POST] Widget unmounted after coords');
return;
}
// CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates
if (coords == null ||
coords.$1.isEmpty ||
coords.$2.isEmpty ||
coords.$1 == '0' ||
coords.$2 == '0') {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})',
);
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
);
return; // Skip this post - don't send invalid coordinates
}
// Validate coordinate ranges
final latDouble = double.tryParse(coords.$1) ?? 0.0;
final lngDouble = double.tryParse(coords.$2) ?? 0.0;
if (latDouble == 0 ||
lngDouble == 0 ||
latDouble.abs() > 90 ||
lngDouble.abs() > 180) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)',
);
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
);
return; // Skip this post - don't send invalid coordinates
}
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}',
);
// CRITICAL: Track cumulative distance traveled from active to Picked up/cancelled
// Calculate distance from last GPS location and accumulate
final pickupId = base['pickupid']?.toString() ?? '';
if (pickupId.isNotEmpty) {
try {
final prefs = await SharedPreferences.getInstance();
// Get last GPS location for this pickup
final lastLatStr =
prefs.getString('pickup_tracking_${pickupId}_lastLat') ?? '';
final lastLngStr =
prefs.getString('pickup_tracking_${pickupId}_lastLng') ?? '';
final currentCumulativeKm =
double.tryParse(
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ??
'0',
) ??
0.0;
final currentLat = double.tryParse(coords.$1) ?? 0.0;
final currentLng = double.tryParse(coords.$2) ?? 0.0;
if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) {
final lastLat = double.tryParse(lastLatStr) ?? 0.0;
final lastLng = double.tryParse(lastLngStr) ?? 0.0;
if (lastLat != 0 &&
lastLng != 0 &&
currentLat != 0 &&
currentLng != 0) {
// Calculate distance from last location to current location
final distanceMeters = Geolocator.distanceBetween(
lastLat,
lastLng,
currentLat,
currentLng,
);
// Only add if distance is significant (filter out GPS noise/jitter)
// Ignore movements less than 10 meters (likely GPS drift)
if (distanceMeters >= 10.0) {
final distanceKm = distanceMeters / 1000.0;
final newCumulativeKm = currentCumulativeKm + distanceKm;
// Save updated cumulative distance
await prefs.setString(
'pickup_tracking_${pickupId}_cumulativeKm',
newCumulativeKm.toStringAsFixed(4),
);
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] 📍 Distance segment: ${distanceKm.toStringAsFixed(4)} km (${distanceMeters.toStringAsFixed(0)}m)',
);
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] 📊 Cumulative distance: ${newCumulativeKm.toStringAsFixed(4)} km',
);
} else {
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] ⏭️ Skipped small movement: ${distanceMeters.toStringAsFixed(1)}m (GPS noise)',
);
}
}
}
// Save current location as last location for next calculation
await prefs.setString(
'pickup_tracking_${pickupId}_lastLat',
coords.$1,
);
await prefs.setString(
'pickup_tracking_${pickupId}_lastLng',
coords.$2,
);
} catch (e) {
debugPrint(
'[PICKUPS][PICKUPLOG][TRACKING] ❌ Error tracking cumulative distance: $e',
);
}
}
final now = DateTime.now();
final logdate =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
// CRITICAL: Ensure starttime is always included in payload
final starttimeValue = basePayload['starttime']?.toString() ?? '';
if (starttimeValue.isEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback',
);
}
// CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point
final payload = {
...basePayload,
'logdate': logdate,
'latitude': coords.$1, // Guaranteed non-null and valid
'longitude': coords.$2, // Guaranteed non-null and valid
'starttime': starttimeValue.isNotEmpty
? starttimeValue
: '', // CRITICAL: Always include starttime
};
// Validate payload has all required fields
final requiredFields = [
'tenantid',
'partnerid',
'locationid',
'orderheaderid',
'pickupid',
'userid',
'orderid',
'orderstatus',
'starttime',
];
final missingFields = requiredFields
.where((field) => payload[field] == null || payload[field] == '')
.toList();
if (missingFields.isNotEmpty) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields',
);
}
debugPrint('[PICKUPS][PICKUPLOG][POST] 📦 Payload: $payload');
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ✅ starttime in payload: "${payload['starttime']}"',
);
await _pickupLogProvider
.createPickupLog(payload)
.timeout(
const Duration(seconds: 8),
onTimeout: () {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ⚠️ API timeout for orderId: $orderId',
);
throw TimeoutException('API timeout', const Duration(seconds: 8));
},
);
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}',
);
} catch (e, stackTrace) {
debugPrint(
'[PICKUPS][PICKUPLOG][POST] ❌ ERROR for orderId: $orderId - $e',
);
debugPrint('[PICKUPS][PICKUPLOG][POST] Stack trace: $stackTrace');
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_picked.isNotEmpty) {
final confirm = await showAppDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
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();
}
},
),
),
],
),
),
);
}
}