2507 lines
97 KiB
Dart
2507 lines
97 KiB
Dart
// 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();
|
||
}
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|