3479 lines
146 KiB
Dart
3479 lines
146 KiB
Dart
// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast
|
||
library;
|
||
|
||
// ── Dead visual effects, removed ──
|
||
//
|
||
// This file imported `confetti`, `scratcher`, `circular_countdown_timer`,
|
||
// `slider_button_lite` and `lottie` alongside the delivery widgets — the
|
||
// remains of a celebratory completion flow. Every one of them had **zero**
|
||
// references left in the file; they were kept alive only by their import
|
||
// lines, which is how a scratch-card and a confetti burst stay one wiring
|
||
// mistake away from a field-service queue.
|
||
//
|
||
// The packages stay in `pubspec.yaml` because other screens use some of them;
|
||
// what is removed here is this file's claim on them.
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:flutter/foundation.dart' show kDebugMode, setEquals;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import 'package:latlong2/latlong.dart' show LatLng;
|
||
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/helpers/poll_policy.dart';
|
||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||
import 'package:miler/views/helpers/widgets/slide_action.dart';
|
||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
|
||
import 'package:miler/views/Dashboard/home/homepage_banner.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:miler/providers/pickup/pickup_provider.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:miler/controllers/pickups_controller.dart';
|
||
import 'package:miler/controllers/duty_controller.dart';
|
||
import 'package:miler/Models/stop_status.dart';
|
||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||
import 'package:miler/widget/Bottom_page.dart';
|
||
import 'package:circular_countdown_timer/circular_countdown_timer.dart';
|
||
import 'dart:math' as math;
|
||
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
|
||
import 'package:miler/controllers/riderlog.dart';
|
||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||
import 'package:miler/data/api_config.dart';
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/consignment_state.dart';
|
||
import 'package:miler/data/proof_store.dart';
|
||
import 'package:miler/views/Dashboard/pickups/delivery_proof_page.dart';
|
||
import 'package:miler/data/mutation_guard.dart';
|
||
import 'package:miler/data/stop_compliance.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:scratcher/scratcher.dart';
|
||
import 'package:confetti/confetti.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
// The two pages the logistics arrival opens: proof of what is being collected,
|
||
// then the shipment desk that establishes where it is going and what it costs.
|
||
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
|
||
import 'package:miler/views/Dashboard/pickups/shipment_capture.dart';
|
||
import 'package:miler/views/Dashboard/pickups/shipment_review.dart';
|
||
import 'package:miler/data/milk_run.dart';
|
||
import 'package:miler/data/order_events.dart';
|
||
import 'package:miler/data/route_order.dart';
|
||
import 'package:miler/data/stop_area.dart';
|
||
import 'package:miler/data/stop_contact.dart';
|
||
import 'package:miler/data/work_domain.dart';
|
||
import 'package:miler/data/work_repository.dart';
|
||
import 'package:miler/data/miler_api.dart';
|
||
import 'package:miler/data/assignment_lookup.dart';
|
||
import 'package:miler/utils/external_navigation.dart';
|
||
|
||
part 'card.dart';
|
||
part 'map.dart';
|
||
part 'pip.dart';
|
||
part 'sheet.dart';
|
||
part 'map_btn.dart';
|
||
part 'multi_map.dart';
|
||
part 'done.dart';
|
||
part 'skip_sheet.dart';
|
||
part 'delivery_actions.dart';
|
||
// `part '../Cart/cartpage.dart';` — REMOVED FROM THE BUILD.
|
||
//
|
||
// 1,070 lines of a Cart screen that nothing in the app ever instantiated:
|
||
// `Cartpage` had no route, no tab and no call site. It compiled into every
|
||
// release, carried its own 30-second poller and its own copy of the stop card,
|
||
// and — because it was never on screen — quietly rotted out of step with the
|
||
// Bookings list it duplicated.
|
||
//
|
||
// The file is still on disk. Delete it once this repo is under version control
|
||
// and you can get it back; until then it is simply out of the binary.
|
||
|
||
/// Helper function to launch phone dialer - works in both debug and release builds
|
||
/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch
|
||
Future<bool> launchPhoneDialer(String phoneNumber) async {
|
||
try {
|
||
// Sanitize phone number: keep only digits and '+'
|
||
final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), '');
|
||
|
||
if (phone.isEmpty) {
|
||
debugPrint(
|
||
'[PHONE] Empty phone number after sanitization, skipping dial',
|
||
);
|
||
return false;
|
||
}
|
||
|
||
final Uri uri = Uri(scheme: 'tel', path: phone);
|
||
|
||
// Try canLaunchUrl first (works in debug, may fail in release)
|
||
bool canLaunch = false;
|
||
try {
|
||
canLaunch = await canLaunchUrl(uri);
|
||
debugPrint('[PHONE] canLaunchUrl result: $canLaunch');
|
||
} catch (e) {
|
||
debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e');
|
||
// Continue anyway - launch might still work
|
||
}
|
||
|
||
// Always attempt to launch, even if canLaunchUrl returned false
|
||
// Using LaunchMode.platformDefault is often safer for system intents like dialing
|
||
try {
|
||
final launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||
if (launched) {
|
||
debugPrint('[PHONE] Successfully launched dialer for: $phone');
|
||
return true;
|
||
} else {
|
||
debugPrint('[PHONE] launchUrl returned false for: $phone');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[PHONE] Failed to launch dialer: $e');
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
debugPrint('[PHONE] Error in launchPhoneDialer: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
class MyPickups extends StatefulWidget {
|
||
const MyPickups({super.key});
|
||
|
||
@override
|
||
State<MyPickups> createState() => _MyPickupsState();
|
||
|
||
/// Forgets a local skip, so the stop stops being re-stamped 'skipped' on the
|
||
/// next poll.
|
||
///
|
||
/// Keyed the way this screen keys everything: pickup id when there is one,
|
||
/// order id otherwise.
|
||
static void forgetSkip({String pickupId = '', String orderId = ''}) {
|
||
final key = pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
|
||
_MyPickupsState._forgetSkip(key);
|
||
}
|
||
|
||
/// Puts a skipped stop back into the working queue.
|
||
///
|
||
/// A skip parks a stop for a return visit, so it waits on **Activity** — the
|
||
/// record of what has happened today — rather than among the live work on
|
||
/// Bookings. Resuming is an accept on the wire, the same call the accept bar
|
||
/// makes, plus clearing the skip in both places that hold one:
|
||
///
|
||
/// • [_skippedOrdersCache], or the 3s poller would re-stamp 'skipped' onto
|
||
/// the stop within seconds and the resume would silently undo itself;
|
||
/// • the persistent store, which is what Home and Activity read.
|
||
///
|
||
/// Optimistic, like every other decision in this app: the local record is
|
||
/// written whatever the network says, and the poll catches up. Returns false
|
||
/// only when there is no order id to act on.
|
||
///
|
||
/// Lives here rather than on the screen that shows the control because two
|
||
/// screens have now owned that control in turn — Home, then Activity — and
|
||
/// the logic moved with it both times, cache-clearing included.
|
||
static Future<bool> resumeSkippedStop(Map<String, dynamic> stop) async {
|
||
final orderId = (stop['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) return false;
|
||
|
||
forgetSkip(pickupId: (stop['pickupid'] ?? '').toString(), orderId: orderId);
|
||
|
||
// ── A delivery park is taken back locally, because that is all it was ──
|
||
//
|
||
// The accept below is the right call for a **pickup** the rider walked
|
||
// away from: the booking really did go back to undecided, and resuming is
|
||
// a genuine re-acceptance.
|
||
//
|
||
// It is the wrong call for a stop skipped at a customer's door. That
|
||
// booking is `Converted_To_Consignment` — collected, converted, past the
|
||
// accept rung entirely — and posting an accept against it is the wrong
|
||
// object at the wrong point in its life. The consignment was never closed
|
||
// by the skip (see `closeDelivery`); the only thing standing between the
|
||
// rider and a second attempt is the local park, so removing it *is* the
|
||
// resume. No server call, because there is no server state to undo.
|
||
if (_isDeliveryLegStop(stop)) {
|
||
await removeSkippedBookings([orderId]);
|
||
await clearOrderEvents(orderId);
|
||
stop['orderstatus'] = 'outfordelivery';
|
||
if (Get.isRegistered<PickupsController>()) {
|
||
Get.find<PickupsController>().triggerRefresh();
|
||
}
|
||
debugPrint('[RESUME] $orderId un-parked on the delivery leg');
|
||
return true;
|
||
}
|
||
|
||
final dc = Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>()
|
||
: Get.put(PickupsController(), permanent: true);
|
||
|
||
String lat = '0';
|
||
String lng = '0';
|
||
try {
|
||
final pos =
|
||
await Geolocator.getLastKnownPosition() ??
|
||
await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy: LocationAccuracy.medium,
|
||
),
|
||
);
|
||
lat = pos.latitude.toStringAsFixed(6);
|
||
lng = pos.longitude.toStringAsFixed(6);
|
||
} catch (e) {
|
||
debugPrint('[RESUME] No location for $orderId: $e');
|
||
}
|
||
|
||
try {
|
||
await dc.updateAcceptedStatus(
|
||
pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0,
|
||
orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0,
|
||
ridersLat: lat,
|
||
ridersLng: lng,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[RESUME] $orderId status call failed: $e');
|
||
}
|
||
|
||
final resumed = Map<String, dynamic>.from(stop)
|
||
..['orderstatus'] = 'accepted';
|
||
await removeSkippedBookings([orderId]);
|
||
await addAcceptedBookings([resumed]);
|
||
// A resumed skip is a genuinely new attempt at the same door, so the first
|
||
// attempt's clocks describe a visit that has been superseded. Same argument
|
||
// `addCompletedBookings` makes when it clears the keys it has just read.
|
||
await clearOrderEvents((resumed['orderid'] ?? '').toString());
|
||
stop['orderstatus'] = 'accepted';
|
||
|
||
dc.triggerRefresh();
|
||
return true;
|
||
}
|
||
|
||
/// True when this stop's remaining work is a **delivery**, not a collection.
|
||
///
|
||
/// Asked of the row rather than of the screen, because resume is offered
|
||
/// from Activity, where there is no queue to consult: a stop that carries a
|
||
/// consignment, or whose status is a delivery rung, is past the collection
|
||
/// half of its life whatever tab it is being looked at from.
|
||
static bool _isDeliveryLegStop(Map<String, dynamic> stop) {
|
||
final consignment = (stop['consignmentid'] ?? '').toString().trim();
|
||
if (consignment.isNotEmpty && consignment != '0') return true;
|
||
if (consignmentStateFromRaw(stop['consignmentstatus']) !=
|
||
ConsignmentState.unknown) {
|
||
return true;
|
||
}
|
||
final status = stopStatusFromRaw(stop['orderstatus']);
|
||
return status.isDeliveryLeg || status.isPicked;
|
||
}
|
||
|
||
// ✅ Public static method to navigate to pickup map screen from outside (e.g., home page)
|
||
static Future<void> navigateToPickupMap(
|
||
BuildContext context,
|
||
Map<String, dynamic> pickup,
|
||
) async {
|
||
await openScreen(
|
||
context,
|
||
_PickupMapScreen(
|
||
pickup: pickup,
|
||
parentState: null, // No parent state when called from outside
|
||
),
|
||
swipeToGoBack: false,
|
||
);
|
||
}
|
||
}
|
||
|
||
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>>[];
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// THE MILK RUN'S ROUND
|
||
//
|
||
// This tab is where a milk-run rider's load lands and where his round
|
||
// starts. Everything below is read on every fetch rather than held from the
|
||
// last action, so the screen is right after a restart mid-shift too.
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
|
||
/// Every stop the hub assigned today, unfiltered.
|
||
///
|
||
/// The gate needs the whole day, not this tab's list: "is the load complete?"
|
||
/// is a question about the orders still sitting on **Home** waiting to be
|
||
/// collected, and those are precisely the ones this screen filters out.
|
||
List<Map<String, dynamic>> _allDayStops = <Map<String, dynamic>>[];
|
||
|
||
Set<String> _acceptedIds = <String>{};
|
||
Set<String> _collectedIds = <String>{};
|
||
Set<String> _outForDeliveryIds = <String>{};
|
||
Set<String> _notLoadedIds = <String>{};
|
||
Set<String> _rejectedIds = <String>{};
|
||
|
||
/// Whether this rider's line delivers what it collects.
|
||
bool get _isMilkRun => ServiceProfile.active.deliversToCustomer;
|
||
|
||
/// What the rail draws: the whole run, finished stops included.
|
||
///
|
||
/// ── Why this is not [_visibleStops] ──
|
||
///
|
||
/// It was, and that is why the rail never moved. `_visibleStops` is the list
|
||
/// of stops still to *work*, and a completed stop is removed from it the
|
||
/// instant it is confirmed — so the rail was handed a set in which nothing
|
||
/// could ever be `done`. Every node stayed grey, the scooter sat on the first
|
||
/// one all day, and finishing a stop made the rail one node SHORTER instead
|
||
/// of one node greener: the rider's only measure of progress went backwards
|
||
/// as he made it.
|
||
///
|
||
/// The completed stops come from the same store the Activity tab reads, so
|
||
/// the two agree about what was finished, and they lead because they were
|
||
/// done first. The open ones follow in the hub's order.
|
||
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, or failing that the first stop still to be done. Skipped
|
||
/// stops keep their own state so they read as unfinished business rather
|
||
/// than either done or next.
|
||
///
|
||
/// The per-stop mapping is [stopProgressFor], which is pure and lives in
|
||
/// `trip.dart` so it can be tested — this State cannot be pumped. Read its
|
||
/// doc for why "physically on" has to be asked in both legs' vocabulary.
|
||
List<StopProgress> _tripProgress() {
|
||
final states = [
|
||
for (final stop in _railStops) stopProgressFor(stopStatusOf(stop)),
|
||
];
|
||
|
||
// No stop is live: promote the first pending one so the rail always shows
|
||
// the rider where he is in the sequence.
|
||
if (!states.contains(StopProgress.current)) {
|
||
final next = states.indexOf(StopProgress.pending);
|
||
if (next >= 0) states[next] = StopProgress.current;
|
||
}
|
||
return states;
|
||
}
|
||
|
||
/// Stage-by-stage counts from the last fetch. Debug only — surfaced on the
|
||
/// empty state so "no cards" reports a cause instead of a symptom.
|
||
String _fetchDiag = '';
|
||
|
||
/// Which of the day's trips each order belongs to, 1-based.
|
||
///
|
||
/// ── Why this is computed from the whole day and not from this list ──
|
||
///
|
||
/// Bookings shows only accepted stops, so grouping *its* list would number
|
||
/// whatever the rider happened to take as "Trip 1" — a miler who declined the
|
||
/// morning slot and took the afternoon one would be told he is riding Trip 1
|
||
/// while the hub, Home and his own supervisor all call it Trip 2. The number
|
||
/// only means something against the day the hub assigned, so it is derived
|
||
/// from every stop the app knows about today — pending, accepted, finished —
|
||
/// before any of this screen's filtering.
|
||
///
|
||
/// Rebuilt on each fetch, so a trip appearing mid-shift renumbers everywhere
|
||
/// at once rather than only here.
|
||
Map<String, int> _tripNumberByOrderId = const {};
|
||
|
||
/// Groups every stop the app knows about today and hands back order id → trip
|
||
/// number. Deduped by order id first: the same stop arrives from up to three
|
||
/// sources per fetch, and counting it twice would not change its trip but
|
||
/// would waste the grouping's arithmetic on copies.
|
||
Map<String, int> _numberTrips(List<Map<String, dynamic>> allToday) {
|
||
final byId = <String, Map<String, dynamic>>{};
|
||
for (final s in allToday) {
|
||
final id = (s['orderid'] ?? '').toString();
|
||
if (id.isEmpty) continue;
|
||
byId.putIfAbsent(id, () => s);
|
||
}
|
||
if (byId.isEmpty) return const {};
|
||
|
||
final trips = Trip.groupIntoTrips(
|
||
byId.values.toList(),
|
||
hubLat: _currentLocation?.latitude,
|
||
hubLng: _currentLocation?.longitude,
|
||
);
|
||
|
||
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,
|
||
];
|
||
|
||
/// Which bag each order is in, as recorded at the counter.
|
||
///
|
||
/// Read once per refresh rather than per card: it is one preferences hit, and
|
||
/// a `FutureBuilder` per row would blink a bagless card in on every rebuild.
|
||
Map<String, String> _bagLabels = const {};
|
||
|
||
Future<void> _loadBagLabels() async {
|
||
final labels = await getBagLabels();
|
||
if (!mounted) return;
|
||
setState(() => _bagLabels = labels);
|
||
}
|
||
|
||
/// ── The queue, in the order it will be worked ──
|
||
///
|
||
/// A delivery list where every card looks the same makes the rider decide
|
||
/// which one is next — a decision the app already knows the answer to. So the
|
||
/// list is cut into three, and only the first section is live:
|
||
///
|
||
/// **NOW** — the one stop he is working. The only enabled card.
|
||
/// **UP NEXT** — the one after it, so he can see where he is heading.
|
||
/// **LATER** — the rest, in order.
|
||
///
|
||
/// Skipped stops sit above NOW, under their own heading: they are earlier in
|
||
/// route order and still resumable, and burying them under LATER would hide
|
||
/// work he has already been to once.
|
||
///
|
||
/// This adds no new sequencing rule. Which card is live was already decided
|
||
/// here — the first non-skipped stop, and nothing while another is running —
|
||
/// and the sections simply say out loud what that logic already meant.
|
||
List<_QueueRow> _queueRows() {
|
||
// ── `isWorkComplete`, not `isPicked` ──
|
||
//
|
||
// This dropped every stop whose raw rung was `picked`. On a parcel route
|
||
// that is correct by accident — collection IS the end of the job there. On
|
||
// a **milk run** `picked` is the middle of the morning: the rider is
|
||
// holding fifteen lunches and has not delivered one of them.
|
||
//
|
||
// So collecting a crate at the kitchen made the card vanish off this screen
|
||
// while the rail above it — which correctly reads the profile-aware
|
||
// predicate — kept drawing the stop as a node. That is the exact symptom:
|
||
// a rail with a stop on it and no card underneath.
|
||
//
|
||
// [StopStatusX.isWorkComplete] is the one that asks "should this still be
|
||
// worked today?", and its own doc says to read it here. Nothing else on
|
||
// this screen was using the raw rung.
|
||
final workable = [
|
||
for (final s in _visibleStops)
|
||
if (!stopStatusOf(s).isCancelled && !stopStatusOf(s).isWorkComplete) s,
|
||
];
|
||
if (workable.isEmpty) return const [];
|
||
|
||
final hasActive = _hasActivePickup();
|
||
final liveIndex = workable.indexWhere((s) => !stopStatusOf(s).isSkipped);
|
||
|
||
final rows = <_QueueRow>[];
|
||
var skippedSeen = false;
|
||
var laterSeen = false;
|
||
|
||
for (var i = 0; i < workable.length; i++) {
|
||
final stop = workable[i];
|
||
final skipped = stopStatusOf(stop).isSkipped;
|
||
final before = i < (liveIndex < 0 ? workable.length : liveIndex);
|
||
|
||
QueueDensity density;
|
||
if (skipped) {
|
||
density = QueueDensity.skipped;
|
||
} else if (i == liveIndex && !hasActive) {
|
||
density = QueueDensity.now;
|
||
} else if (i == liveIndex || i == liveIndex + 1) {
|
||
// While another stop is already running there is no live card at all —
|
||
// the one he is working is on its own screen — so the head of the queue
|
||
// reads as "next" rather than pretending to be actionable.
|
||
density = QueueDensity.next;
|
||
} else {
|
||
density = QueueDensity.later;
|
||
}
|
||
|
||
if (skipped && !skippedSeen && before) {
|
||
skippedSeen = true;
|
||
rows.add(const _QueueRow.heading('SKIPPED'));
|
||
} else if (density == QueueDensity.now) {
|
||
rows.add(const _QueueRow.heading('NOW'));
|
||
} else if (!skipped && i == liveIndex + (hasActive ? 0 : 1)) {
|
||
rows.add(const _QueueRow.heading('UP NEXT'));
|
||
} else if (density == QueueDensity.later && !laterSeen) {
|
||
laterSeen = true;
|
||
rows.add(_QueueRow.heading('LATER', count: workable.length - i));
|
||
}
|
||
|
||
rows.add(
|
||
_QueueRow.stop(
|
||
stop: stop,
|
||
density: density,
|
||
displayStep: _getDisplayStepNumber(
|
||
_visibleStops,
|
||
_visibleStops.indexOf(stop),
|
||
),
|
||
bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '',
|
||
reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '',
|
||
),
|
||
);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
/// Puts a skipped stop back in the queue.
|
||
///
|
||
/// The work itself is [MyPickups.resumeSkippedStop] — a static, because three
|
||
/// screens have owned this control in turn and the logic moved with it every
|
||
/// time. This is only the screen-local half: refresh the list so the row
|
||
/// leaves SKIPPED and rejoins the queue in route order.
|
||
Future<void> _resumeSkipped(Map<String, dynamic> stop) async {
|
||
final ok = await MyPickups.resumeSkippedStop(stop);
|
||
if (!mounted || !ok) return;
|
||
AppFeedback.successGlobal('Back in your queue');
|
||
await _fetchPicked(force: true);
|
||
}
|
||
|
||
/// Shown when the rider has accepted work but none of it is outstanding.
|
||
///
|
||
/// With the trip tabs gone this is a much simpler state than it was: there is
|
||
/// no longer a "this tab is empty but the others are not" case to explain,
|
||
/// because there are no tabs to be on the wrong one of. Either there is work
|
||
/// on this screen or the day is done.
|
||
///
|
||
/// Must stay scrollable — it is the child of a `RefreshIndicator`, and
|
||
/// pull-to-refresh is the rider's way out of it.
|
||
Widget _buildTripTabEmptyState() {
|
||
return ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(
|
||
parent: BouncingScrollPhysics(),
|
||
),
|
||
padding: EdgeInsets.fromLTRB(24.w, 48.h, 24.w, 24.h),
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: 62.w,
|
||
height: 62.w,
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.neutralLight,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: ColorConstants.borderStrong),
|
||
),
|
||
child: Icon(
|
||
LucideIcons.circleCheck,
|
||
size: 28.sp,
|
||
color: ColorConstants.acceptGreen,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 14.h),
|
||
Text(
|
||
'All your stops are done',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: -0.3,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 6.h),
|
||
Text(
|
||
'Anything you accept on Home lands here. Pull down to refresh.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 12.5.sp,
|
||
height: 1.45,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
_fetchDiagLine(),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// The last fetch's stage counts, shown under an empty state in debug only.
|
||
/// Never compiled into release — [kDebugMode] is a const, so the whole widget
|
||
/// folds away.
|
||
Widget _fetchDiagLine() {
|
||
if (!kDebugMode || _fetchDiag.isEmpty) return const SizedBox.shrink();
|
||
return Padding(
|
||
padding: EdgeInsets.only(top: 18.h),
|
||
child: Text(
|
||
'debug · $_fetchDiag\n'
|
||
'${_bookingTrips.length} trip(s) · showing ${_visibleStops.length}',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 10.sp,
|
||
height: 1.5,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTripRail() {
|
||
// The whole run, finished stops included — see [_railStops]. Drawn as long
|
||
// as *anything* happened today, so a rider who has just closed his last
|
||
// stop still sees the full green rail rather than the strip vanishing at
|
||
// the moment it finally reads 100%.
|
||
final railStops = _railStops;
|
||
if (railStops.isEmpty) return const SizedBox.shrink();
|
||
|
||
final states = _tripProgress();
|
||
final trip = Trip.fromStops(
|
||
id: 'active',
|
||
stops: railStops,
|
||
hubLat: _currentLocation?.latitude,
|
||
hubLng: _currentLocation?.longitude,
|
||
);
|
||
|
||
// Time left is only the work still outstanding — counting finished stops
|
||
// would keep the estimate flat all day and make it useless.
|
||
var remaining = Duration.zero;
|
||
for (var i = 0; i < railStops.length && i < states.length; i++) {
|
||
if (states[i] == StopProgress.done) continue;
|
||
remaining += Trip.serviceTimeFor(railStops[i]);
|
||
remaining += trip.travelTimeToStop(
|
||
i,
|
||
hubLat: _currentLocation?.latitude,
|
||
hubLng: _currentLocation?.longitude,
|
||
// Door to door on a round — the walk over the pickup pairs was a
|
||
// tour of kitchens already behind him. See [Trip.travelTimeToStop].
|
||
deliveryLeg: _isMilkRun,
|
||
);
|
||
}
|
||
|
||
// ── Name the trip he is riding ──
|
||
//
|
||
// The rail headline read "10:00 AM – 1:00 PM", or "Your route" when the
|
||
// backend sent no slot — neither of which answers the question the rider
|
||
// actually asks himself on this screen, which is *which of today's trips am
|
||
// I on*. He is given three, they are numbered on Home, the hub refers to
|
||
// them by number on the phone, and this screen was the one place in the
|
||
// flow that would not say.
|
||
//
|
||
// The number leads and the window follows it, because the number is the
|
||
// shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM".
|
||
final tripNo = _currentTripNumber;
|
||
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,
|
||
);
|
||
}
|
||
|
||
/// Which rule ordered the queue the rider is looking at.
|
||
///
|
||
/// Shown on the list — see [_orderNote] — because a queue ordered by the
|
||
/// app's own guess and one ordered by the hub's solved route look identical,
|
||
/// and only one of them is a plan anyone else knows about.
|
||
RouteOrderSource _orderSource = RouteOrderSource.backendOrder;
|
||
|
||
/// Puts the delivery queue in the order it is to be worked.
|
||
///
|
||
/// ── What this used to do, and why it was wrong ──
|
||
///
|
||
/// It split the list in two: stops carrying a `step` were sorted by it, and
|
||
/// everything else was sorted **by straight-line distance from the rider's
|
||
/// current GPS fix** and appended. Two failures in one.
|
||
///
|
||
/// The first is that it produced a list that was neither order — half the
|
||
/// hub's route, half the app's. The second is that in practice it was
|
||
/// *always* the second half: `step` reached the app only through
|
||
/// `GET /miler/assignments`, which is deliberately the active queue
|
||
/// (`Assigned`/`Accepted`), and a booking leaves those states the instant it
|
||
/// is collected. Every delivery therefore arrived here unsequenced and was
|
||
/// re-planned nearest-first, while the hub believed its route was being
|
||
/// followed and the rider had no way to know it was not.
|
||
///
|
||
/// The sequence now comes down on the booking row itself, and the rule is
|
||
/// [RouteOrder]'s — the same one the pickup leg uses. Distance is the last
|
||
/// resort and never mixes with an assigned order.
|
||
List<Map<String, dynamic>> _sortOrders(List<Map<String, dynamic>> bookings) {
|
||
if (bookings.isEmpty) return bookings;
|
||
|
||
final (ordered, source) = RouteOrder.sort(
|
||
bookings,
|
||
bookedTimeOf: (o) => DateTime.tryParse(
|
||
(o['expected_pickup_time'] ?? o['expectedpickuptime'] ?? o['eta'] ?? '')
|
||
.toString(),
|
||
),
|
||
// Offered, not preferred: [RouteOrder.sort] reaches for it only when no
|
||
// stop in the set carries a sequence and none carries a booked time.
|
||
distanceTo: _currentLocation == null
|
||
? null
|
||
: (o) => _calculateDistanceFromCurrent(o),
|
||
);
|
||
_orderSource = source;
|
||
if (!source.isAdmin) {
|
||
RouteOrder.logUnsequenced('deliveries', bookings.length);
|
||
}
|
||
return ordered;
|
||
}
|
||
|
||
int _getDisplayStepNumber(List<Map<String, dynamic>> allOrders, int index) {
|
||
final Booking = allOrders[index];
|
||
// Use preserved step number if available, otherwise get current step
|
||
final step = _getPreservedOrCurrentStep(Booking);
|
||
|
||
if (step > 0) return step;
|
||
|
||
// For bookings without step numbers, calculate based on bookings that have step numbers
|
||
final ordersWithStep = allOrders
|
||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||
.length;
|
||
return ordersWithStep + (index - ordersWithStep) + 1;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
_loadDutyState();
|
||
|
||
// Listen for external refresh triggers
|
||
try {
|
||
final dc = Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>()
|
||
: Get.put(PickupsController());
|
||
|
||
ever(dc.refreshTrigger, (_) {
|
||
if (mounted) {
|
||
debugPrint(
|
||
'[MYPICKUPS] External trigger -> Refreshing picked bookings',
|
||
);
|
||
_fetchPicked(force: true);
|
||
}
|
||
});
|
||
} catch (_) {}
|
||
|
||
// ── Re-read whenever this tab comes forward ──
|
||
//
|
||
// The shell keeps all four tabs alive in an `IndexedStack`, so arriving
|
||
// here is not a build — it is an index change, and nothing about it asks
|
||
// this screen for fresh data. The poller does eventually, up to three
|
||
// seconds later, which is exactly long enough for a rider who has just
|
||
// accepted a stop on Home to arrive, find it missing, and conclude the
|
||
// accept failed. Activity carries the same listener for the same reason.
|
||
BottomPage.currentTab.addListener(_onTabChanged);
|
||
|
||
// Run these in parallel
|
||
_restoreSkips();
|
||
_restoreClosedToday();
|
||
_restoreActivePickup();
|
||
_initializeLocation(); // Don't block
|
||
_fetchPicked();
|
||
_loadBagLabels();
|
||
_startPollingStream();
|
||
}
|
||
|
||
/// This screen's slot in the shell. See [BottomPage.goToTab].
|
||
static const int _bookingsTabIndex = 1;
|
||
|
||
void _onTabChanged() {
|
||
if (BottomPage.currentTab.value == _bookingsTabIndex && mounted) {
|
||
_fetchPicked(force: true);
|
||
}
|
||
}
|
||
|
||
/// Re-loads today's skips into [_skippedOrdersCache] after a cold start.
|
||
///
|
||
/// The cache is static but not persistent, so killing the app used to lose
|
||
/// every skip taken that shift: the stop came back from the queue with
|
||
/// whatever status the backend last recorded and dropped straight back into
|
||
/// the working list, ahead of stops the rider had not skipped. The store that
|
||
/// Activity reads is the same one, so both screens now agree after a restart.
|
||
Future<void> _restoreSkips() async {
|
||
try {
|
||
final skipped = await getSkippedBookings();
|
||
for (final b in skipped) {
|
||
final key = _getOrderKey(b);
|
||
_skippedOrdersCache[key] = (b['skipreason'] ?? '').toString();
|
||
_skippedOrderTimestamps[key] =
|
||
DateTime.tryParse(
|
||
(b['skippedat'] ?? '').toString(),
|
||
)?.millisecondsSinceEpoch ??
|
||
DateTime.now().millisecondsSinceEpoch;
|
||
}
|
||
if (skipped.isNotEmpty) {
|
||
debugPrint('[MYPICKUPS] Restored ${skipped.length} skip(s) from store');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[MYPICKUPS] Could not restore skips: $e');
|
||
}
|
||
}
|
||
|
||
/// Re-loads the stops already closed today, so a restart cannot put one
|
||
/// back on this tab.
|
||
///
|
||
/// ── The reappearing skip ──
|
||
///
|
||
/// [_completedOrderKeys] is what stops the 3-second poller resurrecting a
|
||
/// stop the rider has just finished, and it was memory-only. That is enough
|
||
/// for a delivery, because the backend agrees within a poll or two — the
|
||
/// consignment goes `Delivered` and the row stops being delivery work.
|
||
///
|
||
/// It is **not** enough for a skip. A skipped stop is a failed attempt, not
|
||
/// a closed consignment: the server rightly leaves it `Out_for_Delivery`,
|
||
/// so the row keeps coming back as live delivery work and only this set was
|
||
/// holding it off the list. Kill the app, and the set went with it — the
|
||
/// stop the rider wrote off an hour ago was back at the top of his queue,
|
||
/// while its record sat in Activity saying it was done. Two screens, two
|
||
/// answers, and the one he had to act on was the wrong one.
|
||
///
|
||
/// The completed store already holds exactly the right rows: what this
|
||
/// device closed, today, under this rider's scope, pruned nightly. Reading
|
||
/// it back is the whole fix.
|
||
Future<void> _restoreClosedToday() async {
|
||
try {
|
||
final closed = await getCompletedBookings();
|
||
if (closed.isEmpty || !mounted) return;
|
||
setState(() {
|
||
for (final b in closed) {
|
||
_completedOrderKeys.add(_getOrderKey(b));
|
||
}
|
||
});
|
||
debugPrint('[MYPICKUPS] Restored ${closed.length} closed stop(s)');
|
||
} catch (e) {
|
||
debugPrint('[MYPICKUPS] Could not restore closed stops: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _restoreActivePickup() async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final savedActiveId = prefs.getString('active_pickup_order_id');
|
||
if (savedActiveId != null && savedActiveId.isNotEmpty) {
|
||
_activePickupOrderId = savedActiveId;
|
||
debugPrint(
|
||
'[MYPICKUPS] Restored active pickup orderId: $savedActiveId',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[MYPICKUPS] Error restoring active pickup: $e');
|
||
}
|
||
}
|
||
|
||
/// Restarts the poller at the speed the rider's current situation deserves.
|
||
///
|
||
/// Cheap to call: if the interval has not changed, the running stream is left
|
||
/// alone rather than torn down and rebuilt (which would reset the clock and,
|
||
/// called from a fetch handler, could starve the poll entirely).
|
||
void _syncPolling() {
|
||
if (!_pollForeground) {
|
||
_pollerSubscription?.cancel();
|
||
_pollerSubscription = null;
|
||
_currentPollInterval = null;
|
||
return;
|
||
}
|
||
|
||
final Duration interval = pollInterval(
|
||
hasLiveWork: _activePickups.isNotEmpty,
|
||
onDuty: _onDuty,
|
||
);
|
||
if (_pollerSubscription != null && _currentPollInterval == interval) return;
|
||
|
||
_pollerSubscription?.cancel();
|
||
_currentPollInterval = interval;
|
||
_pollerSubscription = Stream.periodic(interval, (_) {})
|
||
.asyncMap((_) async {
|
||
if (mounted && !_fetching) {
|
||
await _fetchPicked();
|
||
}
|
||
})
|
||
.listen(
|
||
(_) {},
|
||
onError: (error) {
|
||
debugPrint('[MYPICKUPS][STREAM ERROR] $error');
|
||
},
|
||
cancelOnError: false,
|
||
);
|
||
}
|
||
|
||
void _startPollingStream() => _syncPolling();
|
||
|
||
/// The screen stays awake only while a stop is actually live.
|
||
///
|
||
/// It used to be held for the whole time Bookings was open — so a rider with
|
||
/// the phone in his pocket and nothing running had a screen that would not
|
||
/// sleep, which is the fastest way to flatten a battery mid-shift. Worse, it
|
||
/// was held *here*, on the list, and not on the map screen where he is
|
||
/// actually navigating and genuinely needs it.
|
||
void _syncWakelock() {
|
||
final bool keepAwake = _activePickups.isNotEmpty;
|
||
if (keepAwake == _wakelockHeld) return;
|
||
_wakelockHeld = keepAwake;
|
||
if (keepAwake) {
|
||
WakelockPlus.enable();
|
||
} else {
|
||
WakelockPlus.disable();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
super.didChangeAppLifecycleState(state);
|
||
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;
|
||
}
|
||
|
||
/// The live stops this screen is allowed to open — see
|
||
/// [MilkRun.worksOnOwnScreen].
|
||
///
|
||
/// Not the same question as [_hasActivePickup]. That one is about the rider
|
||
/// (is a job running at all — it drives the poll rate, the wakelock and the
|
||
/// exit warning) and stays as it is. This one is about *this screen*: whether
|
||
/// the running job is one it can take him into.
|
||
List<Map<String, dynamic>> get _liveOnThisScreen => [
|
||
for (final p in _activePickups)
|
||
if (MilkRun.worksOnOwnScreen(p, collectedIds: _collectedIds)) p,
|
||
];
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// START DELIVERY — the load leaves the counter
|
||
//
|
||
// ── Why this is a press and not a side effect of the pickup ──
|
||
//
|
||
// The release used to ride along with `pickup-complete`, back on Home: the
|
||
// rider slid to confirm five bags at a kitchen and the same gesture posted
|
||
// all five consignments `Out_for_Delivery`. The hub's board then showed five
|
||
// *active* deliveries for food still on the shelf in front of him, and there
|
||
// was no moment left in the day that meant "I have set off" — picked and
|
||
// out-for-delivery had been collapsed into one claim.
|
||
//
|
||
// They are two different claims about where the load is, made minutes or an
|
||
// hour apart, and only the rider can make the second one. So it is his press,
|
||
// on the tab that holds the load.
|
||
//
|
||
// ── Why one bar for the whole load ──
|
||
//
|
||
// `POST /miler/deliveries/start` takes a batch because the gesture is one
|
||
// press after the last counter. Releasing per card would be five presses for
|
||
// one act and would leave the round half-open — some rows deliverable, some
|
||
// refused at the door by a server the rider cannot argue with.
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
|
||
/// Orders in the rider's hands that he has not yet declared himself riding.
|
||
///
|
||
/// The bar's subject, and the reason it appears at all: an empty answer means
|
||
/// the whole load is already on the road and there is nothing left to press.
|
||
///
|
||
/// Empty on every line but the milk run, and deliberately: a logistics parcel
|
||
/// is collected *for the hub* and delivered by somebody else, so its rider
|
||
/// has no round to start and must never be shown a control that says he has.
|
||
/// [MilkRun.navigatesToCustomer] asks the same question one stop at a time.
|
||
List<Map<String, dynamic>> get _unreleased => !_isMilkRun
|
||
? const []
|
||
: [
|
||
for (final s in _visibleStops)
|
||
// The same three exclusions the queue itself applies, so the bar
|
||
// can never count a row the rider cannot see: work he has already
|
||
// closed, work he has put down, and work already on the road.
|
||
if (!stopStatusOf(s).isWorkComplete &&
|
||
!stopStatusOf(s).isCancelled &&
|
||
!stopStatusOf(s).isSkipped &&
|
||
!_completedOrderKeys.contains(_getOrderKey(s)) &&
|
||
_needsRelease(s))
|
||
s,
|
||
];
|
||
|
||
/// Whether this stop is still waiting on the rider's **Start delivery**.
|
||
///
|
||
/// ── Read from the consignment, not from a flag in this app ──
|
||
///
|
||
/// Two backend behaviours are live at once and the app must not care which
|
||
/// it is talking to. `pickup-complete` either leaves a hyperlocal
|
||
/// consignment `Out_for_Delivery` (as it does today) or stops it at
|
||
/// `Collected_By_Miler` and waits for `start-delivery` — a server-side
|
||
/// setting, not something this app is told about.
|
||
///
|
||
/// So the row is asked. `consignmentstatus` ships on every
|
||
/// `GET /miler/bookings` row, and it answers the question exactly: needing
|
||
/// release means needing release, and already out for delivery means the bar
|
||
/// has nothing to offer. **No feature flag is mirrored here**, because a
|
||
/// mirrored flag is a second source of truth that can be wrong.
|
||
///
|
||
/// ── The rider's press decides this, not the server's rung ──
|
||
///
|
||
/// This read the consignment state first and only fell back to the local
|
||
/// sets when the row carried none. That is the right instinct — a local flag
|
||
/// must never outrank a server fact — and it produced the wrong answer for
|
||
/// the case that is live on production today.
|
||
///
|
||
/// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED`
|
||
/// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's
|
||
/// old reading is exactly right. With it **off** — compatibility mode, which
|
||
/// is the default and what the app meets in production — `pickup-complete`
|
||
/// releases the consignment itself, so a stop arrives on this tab already
|
||
/// `Out_for_Delivery`, `needsRelease` answers false, and the round starts
|
||
/// itself. The rider never sees PICKED and **Start delivery** never appears:
|
||
/// he collects at the kitchen and the app has him on the road before he has
|
||
/// left the counter.
|
||
///
|
||
/// So the two questions are separated, because they were never the same one:
|
||
///
|
||
/// ```
|
||
/// may this be delivered? → the consignment. ConsignmentGate, deliver
|
||
/// has he set off? → the rider. this method
|
||
/// ```
|
||
///
|
||
/// Nothing here contradicts the server. A stop the app holds at PICKED is
|
||
/// still `Out_for_Delivery` on the wire, `deliver` is still gated on the
|
||
/// consignment's own state, and pressing **Start delivery** on a consignment
|
||
/// that is already released costs no request at all — `releaseForDelivery`
|
||
/// returns true on sight of a deliverable state. What changes is only which
|
||
/// of the two facts the rider's card is showing him, and the answer is the
|
||
/// one he can act on.
|
||
///
|
||
/// The consignment state stays the fallback for a stop this device has no
|
||
/// record of — collected on another handset, or after a reinstall.
|
||
bool _needsRelease(Map<String, dynamic> stop) {
|
||
final id = MilkRun.idOf(stop);
|
||
// He pressed it. Nothing outranks that.
|
||
if (_outForDeliveryIds.contains(id)) return false;
|
||
// He collected it here and has not pressed it.
|
||
if (_collectedIds.contains(id)) return true;
|
||
return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease;
|
||
}
|
||
|
||
/// True while the round is being marked started, so the bar cannot be
|
||
/// double-pressed.
|
||
bool _starting = false;
|
||
|
||
/// Sets off: releases each collected consignment for delivery, server first.
|
||
///
|
||
/// ── This is a real write now ──
|
||
///
|
||
/// It has been three different things, and the history matters because two
|
||
/// of them were wrong in opposite directions.
|
||
///
|
||
/// It began as `POST /miler/deliveries/start` — a route that is not on the
|
||
/// contract and not in the backend's table. It 404'd every time, so the bar
|
||
/// could never succeed. It was then cut back to a purely local record, on
|
||
/// the evidence that `pickup-complete` was itself pushing hyperlocal work
|
||
/// to `Out_for_Delivery`: the load really was released before it reached
|
||
/// this tab, and the press only recorded what the rider had done with it.
|
||
///
|
||
/// The backend closed that gap on 21 Aug 2026. `pickup-complete` now stops
|
||
/// at `Collected_By_Miler` — collected, in the rider's hands, not yet on the
|
||
/// road — and `POST /miler/consignments/:id/start-delivery` makes the
|
||
/// release. So the press has a server meaning again, and the hub stops
|
||
/// seeing "out for delivery" for food still on the kitchen counter.
|
||
///
|
||
/// **The local set is written only for what the server released.** A stop
|
||
/// whose consignment could not be reached or was refused keeps its PICKED
|
||
/// word and stays on the bar, because the alternative — showing ACTIVE for
|
||
/// a consignment the hub still calls collected — is the fiction that made
|
||
/// `deliver` fail at the door with nothing on screen to explain why.
|
||
///
|
||
/// Returns the number of orders actually released.
|
||
Future<int> startRound(List<Map<String, dynamic>> stops) async {
|
||
if (_starting || stops.isEmpty) return 0;
|
||
setState(() => _starting = true);
|
||
try {
|
||
final released = <String>[];
|
||
var unreachable = 0;
|
||
|
||
for (final stop in stops) {
|
||
final orderId = MilkRun.idOf(stop);
|
||
if (orderId.isEmpty) continue;
|
||
if (await releaseForDelivery(stop)) {
|
||
released.add(orderId);
|
||
} else {
|
||
unreachable++;
|
||
}
|
||
}
|
||
_unreleasedFailures = unreachable;
|
||
if (released.isEmpty) return 0;
|
||
|
||
await addOutForDeliveryOrderIds(released);
|
||
await stampOrderEvents(released, OrderEvent.outForDelivery);
|
||
if (mounted) {
|
||
setState(
|
||
() => _outForDeliveryIds = {..._outForDeliveryIds, ...released},
|
||
);
|
||
}
|
||
return released.length;
|
||
} catch (e) {
|
||
debugPrint('[MILKRUN] could not record the round starting: $e');
|
||
return 0;
|
||
} finally {
|
||
if (mounted) setState(() => _starting = false);
|
||
}
|
||
}
|
||
|
||
/// How many stops the last [startRound] could not release. Read once by
|
||
/// [_startWholeRound] to word the message honestly.
|
||
int _unreleasedFailures = 0;
|
||
|
||
/// Scroll extent to reserve under the queue for whatever floats over it.
|
||
///
|
||
/// Reads the same two conditions the slot itself reads, so the floor and the
|
||
/// surface can never disagree about which one is on screen.
|
||
double _bottomReservation(BuildContext context) => bottomReservationFor(
|
||
inset: BottomPage.bottomInset(context),
|
||
live: _liveOnThisScreen.isNotEmpty,
|
||
unreleased: _unreleased.isNotEmpty,
|
||
idle: 8.h,
|
||
);
|
||
|
||
Future<void> _startWholeRound() async {
|
||
final stops = _unreleased;
|
||
final n = await startRound(stops);
|
||
if (!mounted) return;
|
||
|
||
final held = _unreleasedFailures;
|
||
if (n == 0) {
|
||
// One stop, one reason: say it. `releaseForDelivery` knows whether the
|
||
// parcel is at a hub, missing its reference, refused, or genuinely
|
||
// unreachable — see [lastReleaseFailure] — and "check your connection"
|
||
// is true for only the last of those.
|
||
//
|
||
// A whole round that failed keeps the general wording: with several
|
||
// stops there is no single reason to give, and the per-stop detail is in
|
||
// the log.
|
||
AppFeedback.errorGlobal(
|
||
held == 1
|
||
? (lastReleaseFailure ??
|
||
'Could not start that delivery. Check your connection and '
|
||
'try again.')
|
||
: 'Could not start the round. Check your connection and try again.',
|
||
);
|
||
return;
|
||
}
|
||
AppFeedback.successGlobal(
|
||
held > 0
|
||
? '$n started · $held could not be started'
|
||
: (n == 1 ? 'Delivery started' : '$n deliveries started'),
|
||
);
|
||
}
|
||
|
||
/// Marks a stop as finished (picked up or cancelled) in local state right
|
||
/// away, so the UI unblocks without waiting for the backend/poll to catch up:
|
||
/// it leaves the active banner, drops out of the top list, and no longer
|
||
/// blocks the next stop.
|
||
void markPickupFinished(
|
||
Map<String, dynamic> order, {
|
||
bool cancelled = false,
|
||
|
||
/// The exact word to file this stop under, when "cancelled" would be a
|
||
/// lie. A skipped delivery is the case: it is not a completion and not a
|
||
/// cancellation, and Activity has a slice for it.
|
||
String? terminalStatus,
|
||
}) {
|
||
final key = _getOrderKey(order);
|
||
final orderId = (order['orderid'] ?? '').toString();
|
||
_completedOrderKeys.add(key);
|
||
_stopPickupPosting(orderId);
|
||
// ── The bonus, before it is overwritten by the next stop ──
|
||
//
|
||
// `PickupsController` computes it when the stop closes and holds it in one
|
||
// observable that the *next* completion replaces. It was read once, to
|
||
// animate a number on the done screen, and then lost — so Activity could
|
||
// never answer "what was that one worth". Carried onto the record here,
|
||
// which is the only moment it is still this stop's.
|
||
try {
|
||
final earned = Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>().lastBonusPoints.value
|
||
: 0;
|
||
if (earned > 0) order['bonuspts'] = earned;
|
||
} catch (_) {
|
||
// No controller registered — a stop closed from a screen that never
|
||
// created one. The row simply has no bonus on it.
|
||
}
|
||
// Write it down. This is the only moment the app knows the stop is
|
||
// finished — the status call is optimistic and the queue endpoints will not
|
||
// report it for a poll or two, if at all on demo data — so without this the
|
||
// Activity tab has nothing to show for a shift's work. See
|
||
// [addCompletedBookings].
|
||
addCompletedBookings(
|
||
[order],
|
||
cancelled: cancelled,
|
||
terminalStatus: terminalStatus,
|
||
)
|
||
.then(
|
||
(_) => debugPrint(
|
||
'[ACTIVITY] recorded $orderId as '
|
||
'${terminalStatus ?? (cancelled ? 'cancelled' : 'picked')}',
|
||
),
|
||
)
|
||
// Fire-and-forget, but not silently: if this write ever fails the
|
||
// Activity tab is empty and there is nothing on screen to say why.
|
||
.catchError(
|
||
(Object e) => debugPrint('[ACTIVITY] failed to record $orderId: $e'),
|
||
);
|
||
// Drop it from the local accepted store so a completed/cancelled stop stops
|
||
// counting as an open booking — otherwise it resurfaces on the Bookings tab
|
||
// (and as a "next stop") after a rebuild, since the backend may still report
|
||
// it in a post-accept state.
|
||
if (orderId.isNotEmpty) removeAcceptedBookings([orderId]);
|
||
// A collected order is no longer being carried once it is through a door.
|
||
// Left in the set it would keep the card off Home for the rest of the
|
||
// install, and tomorrow's run would inherit it.
|
||
if (orderId.isNotEmpty) removeCollectedOrderIds([orderId]);
|
||
// …and it is no longer on the round. Same reason: an id left in the
|
||
// released set keeps a delivered stop on this tab for the life of the
|
||
// install.
|
||
if (orderId.isNotEmpty) removeOutForDeliveryOrderIds([orderId]);
|
||
|
||
// ── "Delivery 3 of 10 done" ──
|
||
//
|
||
// A meal run is one motion repeated: door, hand over, next. What a rider
|
||
// cannot see from inside it is how much is left, and a queue that silently
|
||
// gets shorter answers that only if he stops to count. Naming the position
|
||
// is the difference between ten identical stops and visible progress.
|
||
//
|
||
// Counted before the setState below removes it, so the number he reads is
|
||
// the one he just finished, not the one after.
|
||
if (ServiceProfile.active.handsOffAtCollection && !cancelled) {
|
||
final total = _picked.length + _completedToday.length;
|
||
final done = _completedToday.length + 1;
|
||
AppFeedback.successGlobal(
|
||
total > 0 ? 'Delivery $done of $total done' : 'Delivery done',
|
||
);
|
||
}
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_activePickups.removeWhere((o) => _getOrderKey(o) == key);
|
||
_picked.removeWhere((o) => _getOrderKey(o) == key);
|
||
if (_activePickupOrderId == orderId) _activePickupOrderId = null;
|
||
// Straight onto the rail, ahead of the store write landing and the next
|
||
// poll reading it back. Newest first, matching the order
|
||
// [getCompletedBookings] returns — the rail reverses it. Without this
|
||
// the node the rider just closed stays grey for a poll cycle, which is
|
||
// the exact moment he looks at it.
|
||
if (!_completedToday.any((o) => _getOrderKey(o) == key)) {
|
||
final stamped = Map<String, dynamic>.from(order);
|
||
// The same terminal verb the completed store writes — a milk-run
|
||
// stop ends `delivered`, a logistics one `picked`. The two must
|
||
// agree or the rail and the store disagree about the same stop.
|
||
stamped['orderstatus'] = cancelled
|
||
? 'cancelled'
|
||
: (ServiceProfile.active.deliversToCustomer
|
||
? 'delivered'
|
||
: 'picked');
|
||
_completedToday.insert(0, stamped);
|
||
}
|
||
});
|
||
}
|
||
// Clear the persisted active-pickup markers so nothing stays "blocked"
|
||
// across a rebuild/restart.
|
||
SharedPreferences.getInstance().then((prefs) async {
|
||
if (_activePickups.isEmpty) {
|
||
await prefs.setBool('has_live_pickup', false);
|
||
await prefs.remove('active_pickup_order_id');
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Bookings still open for pickup (excludes finished/cancelled), used to seed
|
||
/// the "next stops" list on the completion screen.
|
||
List<Map<String, dynamic>> remainingPickups() {
|
||
return _picked.where((o) {
|
||
return !stopStatusOf(o).isFinishedPickup &&
|
||
!_completedOrderKeys.contains(_getOrderKey(o));
|
||
}).toList();
|
||
}
|
||
|
||
// Previously showed a blocking dialog; now we just disable conflicting buttons in the UI.
|
||
void _showActivePickupBlockMessage() {}
|
||
|
||
/// A rider must be online (on duty) to start/continue a pickup or delivery.
|
||
/// Returns true if online; otherwise shows a prompt and returns false.
|
||
Future<bool> ensureOnlineForAction() async {
|
||
// Single source of truth — same resolution the Home screen uses.
|
||
final bool online = await DutyController.to.load();
|
||
if (!online && mounted) {
|
||
AppFeedback.info(
|
||
context,
|
||
"You're offline. Go online from Home to start pickups.",
|
||
);
|
||
}
|
||
return online;
|
||
}
|
||
|
||
Future<void> startPickup(Map<String, dynamic> item) async {
|
||
if (!mounted) return;
|
||
|
||
// ── A collection is not worked from this screen ──
|
||
//
|
||
// On a kitchen line the rungs up to *picked up* belong to Home, in bulk:
|
||
// the rider is handed a stack of bags at one counter and slides once for
|
||
// all of them. Opening this stop's own map/arrive/confirm screen for a
|
||
// collection gave him a second, contradictory way to work the same rung —
|
||
// and it posted the booking ACTIVE from a tab that is supposed to be his
|
||
// load, so a stop he had not collected started reporting as live.
|
||
//
|
||
// Refused before the status write below, not after it. See
|
||
// [MilkRun.worksOnOwnScreen].
|
||
if (!MilkRun.worksOnOwnScreen(item, collectedIds: _collectedIds)) {
|
||
AppFeedback.info(
|
||
context,
|
||
'Collections are confirmed on Home — select the orders at the '
|
||
'kitchen and slide to update.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Online-gated: only an on-duty rider can start a pickup/delivery.
|
||
if (!await ensureOnlineForAction()) return;
|
||
if (!mounted) return;
|
||
|
||
// ✅ BLOCK: Check if there's already an active pickup (and this isn't it)
|
||
final currentOrderId = (item['orderid'] ?? '').toString();
|
||
final activeOrderIds = _activePickups
|
||
.map((d) => (d['orderid'] ?? '').toString())
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet();
|
||
|
||
// Allow if this IS the active pickup, block if there's a different active pickup
|
||
if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) {
|
||
// Just ignore taps on other cards when a different active pickup exists.
|
||
return;
|
||
}
|
||
|
||
// Verify status is updated to ACTIVE before navigating
|
||
final dc = Get.put(PickupsController());
|
||
final dId = int.tryParse((item['pickupid'] ?? 0).toString()) ?? 0;
|
||
final ohId = int.tryParse((item['orderheaderid'] ?? 0).toString()) ?? 0;
|
||
|
||
if (dId > 0 && ohId > 0) {
|
||
// We don't block navigation on failure, but we try to update
|
||
// This ensures 'starttime' is generated and saved
|
||
await dc.updateActiveStatus(
|
||
pickupId: dId,
|
||
orderHeaderId: ohId,
|
||
orderId: (item['orderid'] ?? '').toString(),
|
||
);
|
||
}
|
||
|
||
// ── Opening a stop does NOT start its leg ──
|
||
//
|
||
// This used to call `startRound([item])` here — the same server release the
|
||
// Start-ride slider makes — on the reasoning that a rider who taps a card
|
||
// without pressing the bar is just as much on his way, and that `deliver`
|
||
// refuses anything that is not `Out_for_Delivery`, so an unreleased stop
|
||
// would walk him to a door and fail him at it.
|
||
//
|
||
// ── Why that is the production lifecycle bug, one tap later ──
|
||
//
|
||
// [MilkRun.navigatesToCustomer] is true the moment a stop is in
|
||
// `_collectedIds`, which is the moment **Picked** succeeds. So the sequence
|
||
// that matters is:
|
||
//
|
||
// ```
|
||
// Home slide Picked → pickup-complete → Collected_By_Miler
|
||
// Work tap the card → start-delivery → Out_for_Delivery ← here
|
||
// Map slide Start ride (already released)
|
||
// ```
|
||
//
|
||
// Tapping a card is not a statement that the rider has set off. It is how
|
||
// he reads an address, checks a phone number, or looks at the route — and
|
||
// it fired the one call that is supposed to be the slider's alone, so the
|
||
// console went **Active** while the load was still on the counter and the
|
||
// rider had not slid anything.
|
||
//
|
||
// It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is
|
||
// off: the pivot has already released the consignment, so
|
||
// `releaseForDelivery` sees a deliverable state and returns true without
|
||
// spending a request. **The flag flip is what arms this.** Flipping it
|
||
// without this change moves the early-Active bug rather than fixing it.
|
||
//
|
||
// The door is still covered, and by the branch written for it: the
|
||
// `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the
|
||
// rider never started, at the moment he actually hands over, then re-reads
|
||
// the state before posting. That is the safety net this pre-release was
|
||
// duplicating — one tap too early and on the wrong side of the boundary.
|
||
//
|
||
// See `MilerLifecycle`, and the regression tests in
|
||
// `test/picked_does_not_release_test.dart`.
|
||
|
||
await openScreen(
|
||
context,
|
||
_PickupMapScreen(pickup: item, parentState: this),
|
||
swipeToGoBack: false,
|
||
);
|
||
}
|
||
|
||
Future<void> resumePickup(Map<String, dynamic> item) async {
|
||
// ✅ BLOCK: Check if there's already an active pickup (and this isn't it)
|
||
final currentOrderId = (item['orderid'] ?? '').toString();
|
||
final activeOrderIds = _activePickups
|
||
.map((d) => (d['orderid'] ?? '').toString())
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet();
|
||
|
||
// Allow if this IS the active pickup, block if there's a different active pickup
|
||
if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) {
|
||
// Ignore resume taps when some other pickup is active.
|
||
return;
|
||
}
|
||
|
||
// For now, resume behaves same as start (navigates to nav screen)
|
||
// You can add specific resume logic here if needed (e.g. un-skip)
|
||
await startPickup(item);
|
||
}
|
||
|
||
Future<void> _fetchPicked({bool force = false}) async {
|
||
if (_fetching) {
|
||
if (force) {
|
||
_fetching = false;
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
_fetching = true;
|
||
// The bag each order is in, re-read with the list it labels: a pickup that
|
||
// happened on Home a moment ago must show its bags here without the rider
|
||
// pulling to refresh.
|
||
unawaited(_loadBagLabels());
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
|
||
|
||
// ✅ CRITICAL: Fetch from v3 API (picked bookings) AND v2 API (all statuses) IN PARALLEL
|
||
|
||
// Define V1 fetcher function (current/in-progress bookings)
|
||
Future<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();
|
||
|
||
// ── This screen does not decide what it owns ──
|
||
//
|
||
// [WorkBoundary] does, for both work screens at once, out of the shared
|
||
// day. That is the whole point of it: the gate used to be written here as
|
||
// a `where` over the raw statuses, Home decided the same thing its own
|
||
// way, and the two could — and did — both claim an accepted booking.
|
||
//
|
||
// Where the boundary sits still follows the line, exactly as it did:
|
||
// logistics hands over at acceptance, a kitchen line at pickup-complete.
|
||
// What changed is that the rule is declared once, on
|
||
// [ServiceProfile.handoffAt], instead of being re-derived on each screen.
|
||
//
|
||
// ── What this gate is deliberately NOT ──
|
||
//
|
||
// It is not the *released* set. Being on this tab and being deliverable
|
||
// are two different things: the round does not open until the rider
|
||
// presses START DELIVERY. That gate lives on the rows and on the bar at
|
||
// the foot of this screen — see [_deliveryLocked] and [MilkRun] — rather
|
||
// than on the fetch, because hiding the orders until release left them on
|
||
// no tab at all: dropped from Home as collected, withheld from here as
|
||
// unreleased.
|
||
final Set<String> collectedIds = await getCollectedOrderIds();
|
||
|
||
// ── Held on the state whatever the line ──
|
||
//
|
||
// Every "which leg is this stop on?" question on this screen and in the
|
||
// sheets it opens reads this set — see [MilkRun.workingKind] and the
|
||
// sheet's `_carrying`. It used to be assigned only inside the milk-run
|
||
// branch below, so on any other line those questions were asked against
|
||
// an empty set and had to fall back to guessing from the raw status.
|
||
if (mounted && !setEquals(_collectedIds, collectedIds)) {
|
||
setState(() => _collectedIds = collectedIds);
|
||
}
|
||
|
||
// The whole day and the sets the round is gated on. Held on the state so
|
||
// the bar at the foot of this screen and the lock on each row read the
|
||
// same facts the fetch just established.
|
||
if (_isMilkRun) {
|
||
final outForDeliveryIds = await getOutForDeliveryOrderIds();
|
||
final notLoadedIds = await getNotLoadedOrderIds();
|
||
final rejectedIds = await getRejectedOrderIds();
|
||
if (mounted) {
|
||
setState(() {
|
||
_allDayStops = items.whereType<Map<String, dynamic>>().toList();
|
||
_acceptedIds = acceptedIds;
|
||
// `_collectedIds` is set above, for every line.
|
||
_outForDeliveryIds = outForDeliveryIds;
|
||
_notLoadedIds = notLoadedIds;
|
||
_rejectedIds = rejectedIds;
|
||
});
|
||
}
|
||
}
|
||
|
||
// One call, one rule — see [WorkBoundary.deliveryQueue]. The second
|
||
// `where` that used to sit here, re-admitting rows by raw status, is gone
|
||
// with it: it was the hole an accepted-but-uncollected booking came
|
||
// through, and a second expression of a fact that now has one.
|
||
// ── What the rider has already written off today ──
|
||
//
|
||
// Read from the two stores that record a mutation *after* it came back
|
||
// OK, so this is authoritative and not a display flag: `completed` holds
|
||
// delivered and cancelled, `skipped` holds a parked attempt. Both are
|
||
// day-stamped and scoped to this rider, tenant and line, and both survive
|
||
// a cold start — which is what stops `/miler/bookings` re-admitting a
|
||
// skipped stop on the next poll while the consignment is still
|
||
// `Out_for_Delivery`. See [WorkBoundary.isClosed].
|
||
final closedIds = <String>{
|
||
...await getCompletedOrderIds(),
|
||
...await getSkippedOrderIds(),
|
||
};
|
||
|
||
var dedupedList = WorkBoundary.deliveryQueue(
|
||
items.whereType<Map<String, dynamic>>(),
|
||
collectedIds: collectedIds,
|
||
acceptedIds: acceptedIds,
|
||
closedIds: closedIds,
|
||
);
|
||
|
||
// Drop stops the rider just finished. The backend may still return them
|
||
// as 'active'/'picked' for a poll cycle or two; without this they'd
|
||
// reappear on the list and re-block the next stop.
|
||
if (_completedOrderKeys.isNotEmpty) {
|
||
dedupedList.removeWhere(
|
||
(o) => _completedOrderKeys.contains(_getOrderKey(o)),
|
||
);
|
||
}
|
||
|
||
// Preserve step numbers for existing bookings
|
||
for (final Booking in dedupedList) {
|
||
final orderKey = _getOrderKey(Booking);
|
||
final currentStep = _getStepNumber(Booking);
|
||
if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) {
|
||
_preservedStepNumbers[orderKey] = currentStep;
|
||
}
|
||
}
|
||
|
||
// Sort the list
|
||
final sortedList = _sortOrders(dedupedList);
|
||
|
||
// Definitive per-order status dump — tells us exactly why an order shows
|
||
// (or doesn't): e.g. "1234:picked" means it's completed, not assignable.
|
||
debugPrint(
|
||
'[MYPICKUPS] fetched statuses='
|
||
'${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}',
|
||
);
|
||
|
||
// ── The poll-time re-stamp is gone ──
|
||
//
|
||
// This used to walk the list writing `orderstatus = 'skipped'` onto every
|
||
// row in the in-memory skip cache, so that the 3s poll could not revert a
|
||
// skip the backend had not recorded. That was a display flag papering
|
||
// over dual ownership: the row was still in the queue, still owned by
|
||
// this tab, and merely wearing a different word.
|
||
//
|
||
// A skipped order no longer reaches this point — `closedIds` drops it at
|
||
// the boundary, above both screens — so there is nothing left to stamp
|
||
// and no second opinion to keep in sync. See [WorkBoundary.isClosed].
|
||
|
||
// Build the top list = the stops he is actually working.
|
||
// - ACTIVE stops go to the banner, not the list.
|
||
// - FINISHED stops (picked / cancelled) must be excluded entirely, not
|
||
// merely hidden per-card. Otherwise the header/route-strip count
|
||
// (_picked.length) included completed stops the cards then rendered as
|
||
// SizedBox.shrink — a "2 stops" ghost with no visible cards. They are
|
||
// on the Activity tab.
|
||
// - SKIPPED stops **stay**, under their own heading.
|
||
//
|
||
// They used to leave, and the reason was sound at the time: left in
|
||
// the list they sat among the live stops looking identical to them,
|
||
// and the rider had to remember which of his eight cards were real. So
|
||
// they were sent to Home to wait with the undecided work.
|
||
//
|
||
// That reason is gone. The queue is no longer one card repeated — a
|
||
// skipped stop now renders as its own density (see [QueueDensity]):
|
||
// under a SKIPPED heading, carrying the reason in warning colour, with
|
||
// an outlined Resume and no primary action. It cannot be mistaken for
|
||
// live work at a glance, which was the entire objection.
|
||
//
|
||
// Keeping it here is also where the rider looks for it. He skipped it
|
||
// twenty minutes ago on this screen, in this queue; sending it to a
|
||
// different tab to be resumed is a hunt for something he never moved.
|
||
final listForTop = sortedList.where((o) {
|
||
final st = stopStatusOf(o);
|
||
return !st.isActive && !st.isFinishedPickup;
|
||
}).toList();
|
||
|
||
debugPrint(
|
||
'[MYPICKUPS] Final top list: ${listForTop.length} '
|
||
'(skipped held on Home: ${_skippedOrdersCache.length})',
|
||
);
|
||
|
||
// Check if data has actually changed before rebuilding
|
||
// This avoids unnecessary setState calls during polling
|
||
// Debug-only trace of every stage this list passes through. Three
|
||
// separate causes have made this tab look empty — a gate on the raw API
|
||
// count, a missing empty state on an unfilled trip tab, and stops
|
||
// filtered out as still-pending — and every one of them looked identical
|
||
// on screen. The counts are now shown on the empty state itself, so the
|
||
// failing stage is visible instead of guessed at.
|
||
_fetchDiag =
|
||
// Which line the app resolved the rider onto. Three of the reports
|
||
// that landed here as "the tab is wrong" were really "the app thinks
|
||
// I am on the other line", and nothing on screen said so.
|
||
'line=${ServiceProfile.active.line.name} '
|
||
'api=${items.length} '
|
||
'accepted=${acceptedIds.length} '
|
||
'workable=${dedupedList.length} '
|
||
'sorted=${sortedList.length} '
|
||
'list=${listForTop.length} '
|
||
'active=${sortedList.where((o) => stopStatusOf(o).isActive).length}';
|
||
debugPrint('[MYPICKUPS][DIAG] $_fetchDiag');
|
||
|
||
if (_fetchFailed && mounted) setState(() => _fetchFailed = false);
|
||
|
||
final bool hasChanged = !_areOrdersEqual(_picked, listForTop);
|
||
|
||
if (hasChanged) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_picked = listForTop;
|
||
});
|
||
debugPrint(
|
||
'[MYPICKUPS] 🔄 UI Updated: ${listForTop.length} bookings',
|
||
);
|
||
}
|
||
} else {
|
||
// No changes
|
||
}
|
||
|
||
// Get all Booking IDs from the current list
|
||
final currentOrderIds = sortedList
|
||
.map((o) => (o['orderid'] ?? '').toString())
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet();
|
||
|
||
// ✅ Find all ACTIVE pickup and ensure timers are running
|
||
final activeOrders = sortedList
|
||
.where((o) => stopStatusOf(o).isActive)
|
||
.toList();
|
||
|
||
debugPrint('[MYPICKUPS] Found ${activeOrders.length} active pickup');
|
||
|
||
// ✅ CRITICAL: Only set has_live_pickup to true if there are ACTIVE bookings (status = "active")
|
||
// Don't set it to true for "picked" or other statuses - only for "active"
|
||
try {
|
||
final hasActiveOrders = activeOrders.isNotEmpty;
|
||
await prefs.setBool('has_live_pickup', hasActiveOrders);
|
||
debugPrint(
|
||
'[MYPICKUPS] Set has_live_pickup: $hasActiveOrders (${activeOrders.length} active bookings)',
|
||
);
|
||
|
||
// Clear active_pickup_order_id if no active pickup
|
||
if (!hasActiveOrders) {
|
||
final activeOrderId = prefs.getString('active_pickup_order_id');
|
||
if (activeOrderId != null && activeOrderId.isNotEmpty) {
|
||
debugPrint(
|
||
'[MYPICKUPS] Clearing stale active_pickup_order_id: $activeOrderId (no active pickup)',
|
||
);
|
||
await prefs.remove('active_pickup_order_id');
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Update active pickup list for banner display
|
||
if (mounted) {
|
||
setState(() {
|
||
_activePickups = activeOrders;
|
||
});
|
||
// Whether a stop is live governs both how fast we poll and whether the
|
||
// screen is held awake, so they are re-derived wherever it changes.
|
||
_syncPolling();
|
||
_syncWakelock();
|
||
}
|
||
|
||
// Get set of active Booking IDs
|
||
final activeOrderIds = activeOrders
|
||
.map((o) => (o['orderid'] ?? '').toString())
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet();
|
||
|
||
// ✅ CRITICAL: Start timers ONLY for active pickup that don't have one yet
|
||
// This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch
|
||
for (final Booking in activeOrders) {
|
||
final orderId = (Booking['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) continue;
|
||
|
||
// ✅ ONLY start timer if it doesn't already exist
|
||
// This prevents restarting timers on every _fetchPicked() call (which happens frequently)
|
||
if (!_pickupTimers.containsKey(orderId)) {
|
||
debugPrint(
|
||
'[MYPICKUPS] Active pickup: $orderId - Starting pickup log posting (timer not found)',
|
||
);
|
||
// ✅ RE-ENABLED: Pickup logs now posted from pickup page with robust validation
|
||
await _startPickupPosting(Booking);
|
||
} else {
|
||
debugPrint(
|
||
'[MYPICKUPS] Active pickup: $orderId - Timer already running, skipping restart',
|
||
);
|
||
}
|
||
|
||
_activePickupOrderId = orderId; // Track the active pickup
|
||
|
||
// Persist active pickup ID so it survives app restarts
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString('active_pickup_order_id', orderId);
|
||
} catch (e) {
|
||
debugPrint('[MYPICKUPS] Error saving active pickup ID: $e');
|
||
}
|
||
}
|
||
|
||
// ✅ Stop timers ONLY for pickup that are no longer active or in the list
|
||
final timersToStop = _pickupTimers.keys
|
||
.where(
|
||
(id) =>
|
||
!activeOrderIds.contains(id) || !currentOrderIds.contains(id),
|
||
)
|
||
.toList();
|
||
|
||
for (final id in timersToStop) {
|
||
debugPrint(
|
||
'[MYPICKUPS] Stopping timer for orderId: $id (no longer active)',
|
||
);
|
||
_stopPickupPosting(id);
|
||
}
|
||
|
||
debugPrint('[MYPICKUPS] Active timers: ${_pickupTimers.keys.toList()}');
|
||
debugPrint('[MYPICKUPS] Active pickup orderId: $_activePickupOrderId');
|
||
} catch (e) {
|
||
debugPrint('[MYPICKUPS] Error fetching picked bookings: $e');
|
||
if (mounted) setState(() => _fetchFailed = true);
|
||
} finally {
|
||
_fetching = false;
|
||
if (!_firstLoadDone && mounted) {
|
||
setState(() => _firstLoadDone = true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ignore: unused_element
|
||
Map<String, dynamic> _createBasePayload(Map<String, dynamic> it) {
|
||
return <String, dynamic>{
|
||
'logid': 0,
|
||
'tenantid': it['tenantid'] ?? 0,
|
||
'partnerid': it['partnerid'] ?? 0,
|
||
'locationid': it['locationid'] ?? 0,
|
||
'orderheaderid': it['orderheaderid'] ?? 0,
|
||
'pickupid': it['pickupid'] ?? 0,
|
||
'userid': it['userid'] ?? 0,
|
||
'orderid': (it['orderid'] ?? '').toString(),
|
||
'orderstatus': 'active',
|
||
};
|
||
}
|
||
|
||
// Stop posting logs for a specific pickup
|
||
void _stopPickupPosting(String orderId) {
|
||
final timer = _pickupTimers.remove(orderId);
|
||
if (timer != null) {
|
||
timer.cancel();
|
||
debugPrint('[PICKUPLOG] Stopped posting logs for orderId: $orderId');
|
||
} else {
|
||
debugPrint('[PICKUPLOG] No timer to stop for orderId: $orderId');
|
||
}
|
||
}
|
||
|
||
Future<(String lat, String lng)?> _getValidCoordinates({
|
||
int retryCount = 0,
|
||
}) async {
|
||
const maxRetries = 3;
|
||
|
||
try {
|
||
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) {
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] Location service disabled, trying last known position',
|
||
);
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null &&
|
||
lastPos.latitude != 0 &&
|
||
lastPos.longitude != 0) {
|
||
final lat = lastPos.latitude.toString();
|
||
final lng = lastPos.longitude.toString();
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] ✅ Using last known position: $lat, $lng',
|
||
);
|
||
return (lat, lng);
|
||
}
|
||
// Retry if we haven't exceeded max retries
|
||
if (retryCount < maxRetries) {
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
return _getValidCoordinates(retryCount: retryCount + 1);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
LocationPermission permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
}
|
||
|
||
if (permission == LocationPermission.deniedForever ||
|
||
permission == LocationPermission.denied) {
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] Permission denied, trying last known position',
|
||
);
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null &&
|
||
lastPos.latitude != 0 &&
|
||
lastPos.longitude != 0) {
|
||
final lat = lastPos.latitude.toString();
|
||
final lng = lastPos.longitude.toString();
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] ✅ Using last known position: $lat, $lng',
|
||
);
|
||
return (lat, lng);
|
||
}
|
||
// Retry if we haven't exceeded max retries
|
||
if (retryCount < maxRetries) {
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
return _getValidCoordinates(retryCount: retryCount + 1);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Position? position;
|
||
try {
|
||
// Try to get current position with higher accuracy
|
||
position = await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy:
|
||
LocationAccuracy.high, // Changed to high for better accuracy
|
||
timeLimit: Duration(seconds: 8), // Increased timeout
|
||
),
|
||
).timeout(const Duration(seconds: 8));
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] Timeout getting current position: $e, trying last known',
|
||
);
|
||
position = await Geolocator.getLastKnownPosition();
|
||
}
|
||
|
||
if (position != null &&
|
||
position.latitude != 0 &&
|
||
position.longitude != 0) {
|
||
final lat = position.latitude.toString();
|
||
final lng = position.longitude.toString();
|
||
|
||
// Validate coordinates are within valid GPS ranges
|
||
final latDouble = double.tryParse(lat) ?? 0.0;
|
||
final lngDouble = double.tryParse(lng) ?? 0.0;
|
||
if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString('last_lat', lat);
|
||
await prefs.setString('last_lng', lng);
|
||
} catch (_) {}
|
||
debugPrint('[PICKUPS][COORDS] ✅ Got valid coordinates: $lat, $lng');
|
||
return (lat, lng);
|
||
} else {
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Fallback to SharedPreferences cached coordinates
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final lat = (prefs.getString('last_lat') ?? '').trim();
|
||
final lng = (prefs.getString('last_lng') ?? '').trim();
|
||
if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') {
|
||
final latDouble = double.tryParse(lat) ?? 0.0;
|
||
final lngDouble = double.tryParse(lng) ?? 0.0;
|
||
if (latDouble != 0 &&
|
||
lngDouble != 0 &&
|
||
latDouble.abs() <= 90 &&
|
||
lngDouble.abs() <= 180) {
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] ✅ Using cached coordinates: $lat, $lng',
|
||
);
|
||
return (lat, lng);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Retry if we haven't exceeded max retries
|
||
if (retryCount < maxRetries) {
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates',
|
||
);
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
return _getValidCoordinates(retryCount: retryCount + 1);
|
||
}
|
||
|
||
debugPrint(
|
||
'[PICKUPS][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries',
|
||
);
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('[PICKUPS][COORDS] ❌ Error getting coordinates: $e');
|
||
// Retry if we haven't exceeded max retries
|
||
if (retryCount < maxRetries) {
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
return _getValidCoordinates(retryCount: retryCount + 1);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> _startPickupPosting(Map<String, dynamic> Booking) async {
|
||
final orderId = (Booking['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) {
|
||
debugPrint('[PICKUPS][PICKUPLOG] ⚠️ Cannot start timer: empty orderId');
|
||
return;
|
||
}
|
||
|
||
// Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above)
|
||
if (_pickupTimers.containsKey(orderId)) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ⚠️ Timer already exists for $orderId, canceling old one',
|
||
);
|
||
_pickupTimers[orderId]?.cancel();
|
||
_pickupTimers.remove(orderId);
|
||
}
|
||
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId',
|
||
);
|
||
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId',
|
||
);
|
||
|
||
// REMOVED: Do not reset cumulative distance here.
|
||
// It is already reset in PickupsController.updateActiveStatus when the status actually changes.
|
||
// Resetting here causes data loss if the app is restarted while a pickup is in progress.
|
||
|
||
// START FOREGROUND SERVICE PROTECTION
|
||
// Ensure the RiderLogController knows we are active so the Foreground Service stays alive
|
||
// This protects THIS timer from being killed by the OS
|
||
try {
|
||
if (Get.isRegistered<RiderLogController>()) {
|
||
final riderLog = Get.find<RiderLogController>();
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] 🛡️ Activating Foreground Service via RiderLogController...',
|
||
);
|
||
// Force 30s interval to match pickup logging
|
||
riderLog.startAutoCreateLoginLoop(seconds: 30);
|
||
}
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ⚠️ Could not start Foreground Service: $e',
|
||
);
|
||
}
|
||
|
||
final pickupId = (Booking['pickupid'] ?? 0).toString();
|
||
|
||
// Get starttime from SharedPreferences (saved when Booking became active via updateActiveStatus)
|
||
// If not found, use activetime from Booking data, or current time as fallback
|
||
String startTime = '';
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
|
||
// Method 1: Get from SharedPreferences (saved when Booking became active)
|
||
startTime = prefs.getString('pickup_starttime_$pickupId') ?? '';
|
||
if (startTime.isNotEmpty) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from SharedPreferences: $startTime',
|
||
);
|
||
}
|
||
|
||
// Method 2: Fallback - try to get from Booking data (starttime field)
|
||
if (startTime.isEmpty) {
|
||
startTime = (Booking['starttime'] ?? Booking['startTime'] ?? '')
|
||
.toString();
|
||
if (startTime.isNotEmpty) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from Booking data: $startTime',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Method 3: Fallback - try activetime from Booking data
|
||
if (startTime.isEmpty) {
|
||
final activetime = (Booking['activetime'] ?? Booking['activTime'] ?? '')
|
||
.toString();
|
||
if (activetime.isNotEmpty) {
|
||
startTime = activetime;
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ✅ Loaded starttime from activetime: $startTime',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called)
|
||
if (startTime.isEmpty) {
|
||
final now = DateTime.now();
|
||
startTime =
|
||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ⚠️ Using current time as starttime fallback: $startTime',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[PICKUPS][PICKUPLOG] ❌ Error getting starttime: $e');
|
||
// Set a fallback starttime even on error
|
||
final now = DateTime.now();
|
||
startTime =
|
||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
// CRITICAL: Ensure starttime is never empty
|
||
if (startTime.isEmpty) {
|
||
final now = DateTime.now();
|
||
startTime =
|
||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ⚠️ Final fallback: starttime was empty, using: $startTime',
|
||
);
|
||
}
|
||
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] 📝 Final starttime for orderId $orderId: $startTime',
|
||
);
|
||
|
||
// Create base payload with starttime
|
||
final base = <String, dynamic>{
|
||
'logid': 0,
|
||
'tenantid': Booking['tenantid'] ?? 0,
|
||
'partnerid': Booking['partnerid'] ?? 0,
|
||
'locationid': Booking['locationid'] ?? 0,
|
||
'orderheaderid': Booking['orderheaderid'] ?? 0,
|
||
'pickupid': Booking['pickupid'] ?? 0,
|
||
'userid': Booking['userid'] ?? 0,
|
||
'orderid': orderId,
|
||
'orderstatus': 'active',
|
||
'starttime': startTime, // Include starttime in base payload
|
||
};
|
||
|
||
_pickupBasePayload[orderId] = base;
|
||
|
||
// Save to SharedPreferences for persistence
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_tenantid',
|
||
(base['tenantid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_partnerid',
|
||
(base['partnerid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_locationid',
|
||
(base['locationid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_orderheaderid',
|
||
(base['orderheaderid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_pickupid',
|
||
(base['pickupid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_userid',
|
||
(base['userid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString('pickuplog_${orderId}_orderid', orderId);
|
||
await prefs.setString('pickuplog_${orderId}_orderstatus', 'active');
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_starttime',
|
||
startTime,
|
||
); // Save starttime
|
||
} catch (e) {
|
||
debugPrint('[PICKUPS][PICKUPLOG] Error saving payload: $e');
|
||
}
|
||
|
||
// Post once immediately (don't await - let it run in background)
|
||
_postPickupLog(orderId, Booking);
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] 📤 Posted initial log for orderId: $orderId',
|
||
);
|
||
|
||
// Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds
|
||
final timer = Timer.periodic(const Duration(seconds: 30), (t) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)',
|
||
);
|
||
_postPickupLog(orderId, Booking);
|
||
});
|
||
|
||
_pickupTimers[orderId] = timer;
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)',
|
||
);
|
||
}
|
||
|
||
// Reset cumulative distance when Booking becomes active
|
||
Future<void> _resetCumulativeDistance(String pickupId) async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.remove('pickup_tracking_${pickupId}_lastLat');
|
||
await prefs.remove('pickup_tracking_${pickupId}_lastLng');
|
||
await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm');
|
||
debugPrint(
|
||
'[PICKUPS] 🧹 Reset cumulative distance tracking for pickupId: $pickupId',
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[PICKUPS] Error resetting cumulative distance: $e');
|
||
}
|
||
}
|
||
|
||
void _postPickupLog(String orderId, Map<String, dynamic> Booking) {
|
||
if (!mounted) {
|
||
debugPrint('[PICKUPS][PICKUPLOG][POST] Widget disposed, skipping');
|
||
return;
|
||
}
|
||
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}',
|
||
);
|
||
|
||
// Use Future.microtask to ensure the async operation runs independently
|
||
Future.microtask(() => _performPost(orderId));
|
||
}
|
||
|
||
Future<void> _performPost(String orderId) async {
|
||
try {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] 🔄 Starting _performPost for orderId: $orderId',
|
||
);
|
||
|
||
Map<String, dynamic>? base = _pickupBasePayload[orderId];
|
||
|
||
if (base == null) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] Base is null, loading from SharedPreferences',
|
||
);
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (!mounted) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] Widget unmounted after prefs load',
|
||
);
|
||
return;
|
||
}
|
||
|
||
base = {
|
||
'logid': 0,
|
||
'tenantid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_tenantid') ?? '0',
|
||
) ??
|
||
0,
|
||
'partnerid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_partnerid') ?? '0',
|
||
) ??
|
||
0,
|
||
'locationid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_locationid') ?? '0',
|
||
) ??
|
||
0,
|
||
'orderheaderid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0',
|
||
) ??
|
||
0,
|
||
'pickupid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_pickupid') ?? '0',
|
||
) ??
|
||
0,
|
||
'userid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_userid') ?? '0',
|
||
) ??
|
||
0,
|
||
'orderid':
|
||
prefs.getString('pickuplog_${orderId}_orderid') ?? orderId,
|
||
'orderstatus':
|
||
prefs.getString('pickuplog_${orderId}_orderstatus') ?? 'active',
|
||
'starttime':
|
||
prefs.getString('pickuplog_${orderId}_starttime') ??
|
||
'', // Load starttime
|
||
};
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] Base loaded from prefs: $base',
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[PICKUPS][PICKUPLOG][POST] Error loading base: $e');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// At this point, base is guaranteed to be non-null (either from cache or created above)
|
||
final basePayload = base; // Flow analysis ensures base is non-null here
|
||
|
||
debugPrint('[PICKUPS][PICKUPLOG][POST] Getting coordinates...');
|
||
|
||
// CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates
|
||
final coords = await _getValidCoordinates().timeout(
|
||
const Duration(seconds: 10), // Increased timeout to allow retries
|
||
onTimeout: () {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ❌ Coordinate timeout after retries',
|
||
);
|
||
return null;
|
||
},
|
||
);
|
||
|
||
if (!mounted) {
|
||
debugPrint('[PICKUPS][PICKUPLOG][POST] Widget unmounted after coords');
|
||
return;
|
||
}
|
||
|
||
// CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates
|
||
if (coords == null ||
|
||
coords.$1.isEmpty ||
|
||
coords.$2.isEmpty ||
|
||
coords.$1 == '0' ||
|
||
coords.$2 == '0') {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})',
|
||
);
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
|
||
);
|
||
return; // Skip this post - don't send invalid coordinates
|
||
}
|
||
|
||
// Validate coordinate ranges
|
||
final latDouble = double.tryParse(coords.$1) ?? 0.0;
|
||
final lngDouble = double.tryParse(coords.$2) ?? 0.0;
|
||
if (latDouble == 0 ||
|
||
lngDouble == 0 ||
|
||
latDouble.abs() > 90 ||
|
||
lngDouble.abs() > 180) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)',
|
||
);
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
|
||
);
|
||
return; // Skip this post - don't send invalid coordinates
|
||
}
|
||
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}',
|
||
);
|
||
|
||
// CRITICAL: Track cumulative distance traveled from active to Picked up/cancelled
|
||
// Calculate distance from last GPS location and accumulate
|
||
final pickupId = base['pickupid']?.toString() ?? '';
|
||
if (pickupId.isNotEmpty) {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
|
||
// Get last GPS location for this pickup
|
||
final lastLatStr =
|
||
prefs.getString('pickup_tracking_${pickupId}_lastLat') ?? '';
|
||
final lastLngStr =
|
||
prefs.getString('pickup_tracking_${pickupId}_lastLng') ?? '';
|
||
final currentCumulativeKm =
|
||
double.tryParse(
|
||
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ??
|
||
'0',
|
||
) ??
|
||
0.0;
|
||
|
||
final currentLat = double.tryParse(coords.$1) ?? 0.0;
|
||
final currentLng = double.tryParse(coords.$2) ?? 0.0;
|
||
|
||
if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) {
|
||
final lastLat = double.tryParse(lastLatStr) ?? 0.0;
|
||
final lastLng = double.tryParse(lastLngStr) ?? 0.0;
|
||
|
||
if (lastLat != 0 &&
|
||
lastLng != 0 &&
|
||
currentLat != 0 &&
|
||
currentLng != 0) {
|
||
// Calculate distance from last location to current location
|
||
final distanceMeters = Geolocator.distanceBetween(
|
||
lastLat,
|
||
lastLng,
|
||
currentLat,
|
||
currentLng,
|
||
);
|
||
|
||
// Only add if distance is significant (filter out GPS noise/jitter)
|
||
// Ignore movements less than 10 meters (likely GPS drift)
|
||
if (distanceMeters >= 10.0) {
|
||
final distanceKm = distanceMeters / 1000.0;
|
||
final newCumulativeKm = currentCumulativeKm + distanceKm;
|
||
|
||
// Save updated cumulative distance
|
||
await prefs.setString(
|
||
'pickup_tracking_${pickupId}_cumulativeKm',
|
||
newCumulativeKm.toStringAsFixed(4),
|
||
);
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][TRACKING] 📍 Distance segment: ${distanceKm.toStringAsFixed(4)} km (${distanceMeters.toStringAsFixed(0)}m)',
|
||
);
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][TRACKING] 📊 Cumulative distance: ${newCumulativeKm.toStringAsFixed(4)} km',
|
||
);
|
||
} else {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][TRACKING] ⏭️ Skipped small movement: ${distanceMeters.toStringAsFixed(1)}m (GPS noise)',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save current location as last location for next calculation
|
||
await prefs.setString(
|
||
'pickup_tracking_${pickupId}_lastLat',
|
||
coords.$1,
|
||
);
|
||
await prefs.setString(
|
||
'pickup_tracking_${pickupId}_lastLng',
|
||
coords.$2,
|
||
);
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][TRACKING] ❌ Error tracking cumulative distance: $e',
|
||
);
|
||
}
|
||
}
|
||
|
||
final now = DateTime.now();
|
||
final logdate =
|
||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||
|
||
// CRITICAL: Ensure starttime is always included in payload
|
||
final starttimeValue = basePayload['starttime']?.toString() ?? '';
|
||
if (starttimeValue.isEmpty) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback',
|
||
);
|
||
}
|
||
|
||
// CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point
|
||
final payload = {
|
||
...basePayload,
|
||
'logdate': logdate,
|
||
'latitude': coords.$1, // Guaranteed non-null and valid
|
||
'longitude': coords.$2, // Guaranteed non-null and valid
|
||
'starttime': starttimeValue.isNotEmpty
|
||
? starttimeValue
|
||
: '', // CRITICAL: Always include starttime
|
||
};
|
||
|
||
// Validate payload has all required fields
|
||
final requiredFields = [
|
||
'tenantid',
|
||
'partnerid',
|
||
'locationid',
|
||
'orderheaderid',
|
||
'pickupid',
|
||
'userid',
|
||
'orderid',
|
||
'orderstatus',
|
||
'starttime',
|
||
];
|
||
final missingFields = requiredFields
|
||
.where((field) => payload[field] == null || payload[field] == '')
|
||
.toList();
|
||
if (missingFields.isNotEmpty) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields',
|
||
);
|
||
}
|
||
|
||
debugPrint('[PICKUPS][PICKUPLOG][POST] 📦 Payload: $payload');
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ✅ starttime in payload: "${payload['starttime']}"',
|
||
);
|
||
|
||
await _pickupLogProvider
|
||
.createPickupLog(payload)
|
||
.timeout(
|
||
const Duration(seconds: 8),
|
||
onTimeout: () {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ⚠️ API timeout for orderId: $orderId',
|
||
);
|
||
throw TimeoutException('API timeout', const Duration(seconds: 8));
|
||
},
|
||
);
|
||
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}',
|
||
);
|
||
} catch (e, stackTrace) {
|
||
debugPrint(
|
||
'[PICKUPS][PICKUPLOG][POST] ❌ ERROR for orderId: $orderId - $e',
|
||
);
|
||
debugPrint('[PICKUPS][PICKUPLOG][POST] Stack trace: $stackTrace');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, result) async {
|
||
if (didPop) return;
|
||
|
||
if (_picked.isNotEmpty) {
|
||
final confirm = await showAppDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
// Same rule as the app bar above: a meal rider has deliveries
|
||
// pending, not pickups. `jobNounPlural` is 'deliveries' on that
|
||
// line and 'bookings' on a parcel route.
|
||
title: Text('Pending ${ServiceProfile.active.workTabLabel}'),
|
||
content: Text(
|
||
'Are you sure you want to close? There are '
|
||
'${ServiceProfile.active.jobNounPlural} pending.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('No'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Yes'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirm == true && context.mounted) {
|
||
Navigator.pop(context);
|
||
}
|
||
} else {
|
||
Navigator.pop(context);
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
// ── The corner has to have something behind it ──
|
||
//
|
||
// The sheet's two top corners were clipping onto a ground painted in
|
||
// the same canvas the sheet itself uses, so the curve revealed the
|
||
// page's own colour and was invisible: this tab read as a hard maroon
|
||
// line above the queue while Home and Activity carried the shape. The
|
||
// brand goes behind the sheet, which is what the other tabs do and
|
||
// what makes the curve a curve. The page's own colour is the sheet's
|
||
// (`MilerSurface.canvas`, one line below), not this.
|
||
backgroundColor: ColorConstants.primary,
|
||
appBar: MilerAppBar(
|
||
// The bar's curve opens onto the sheet's canvas, not the app's
|
||
// paper. See [milerBrandSurface].
|
||
pageColor: MilerSurface.canvas,
|
||
// ── The tab and the page it opens must agree ──
|
||
//
|
||
// This was the literal 'My Bookings', while the nav tab underneath it
|
||
// already read the noun off the profile
|
||
// (`ServiceProfile.active.workTabLabel`, see `Bottom_page.dart`). So a
|
||
// meal rider tapped a tab called **Deliveries** and arrived on a page
|
||
// titled **My Bookings** — the one screen where the two words sit a
|
||
// thumb apart was the one screen that disagreed with itself.
|
||
//
|
||
// Same source as the tab now: "My Deliveries" on a meal run, "My
|
||
// Bookings" on a parcel route. See [ServiceProfile.jobNounPlural].
|
||
title: 'My ${ServiceProfile.active.workTabLabel}',
|
||
trailing: MapViewRow(
|
||
pickup: _picked,
|
||
preservedStepNumbers: _preservedStepNumbers,
|
||
),
|
||
),
|
||
// ── This screen is cards on a ground; Home is rows on a surface ──
|
||
//
|
||
// Deliveries is the one work screen whose content genuinely *is* a
|
||
// stack of raised objects: the NOW card is a layer-2 command surface,
|
||
// and UP NEXT and LATER recede beneath it. A layer-2 surface needs a
|
||
// layer-0 ground to sit on, and this page was giving it white — so the
|
||
// card that is supposed to dominate the screen was separating from its
|
||
// page by 1.124 : 1, and had to carry a border and a shadow to be seen
|
||
// at all.
|
||
//
|
||
// Home and Activity keep the working layer, because they have no cards
|
||
// by design — rows on a spine. Same ladder, different rung, and the
|
||
// rung is chosen by what the screen actually draws. See
|
||
// [MilerSurface].
|
||
body: MilerSheet(
|
||
color: MilerSurface.canvas,
|
||
// ── The sheet has to fill the body, not the queue ──
|
||
//
|
||
// A `Stack` hands its children loose constraints and then shrinks to
|
||
// the tallest of them, so on a short queue — or the empty state —
|
||
// the sheet ended where the content did and the rest of the body was
|
||
// whatever the Scaffold was painted with. That was invisible while
|
||
// the ground was the same canvas as the sheet; the moment the brand
|
||
// went behind it (for the corners) it became a red band across the
|
||
// bottom of the tab.
|
||
child: SizedBox.expand(
|
||
child: Stack(
|
||
children: [
|
||
// Three states, cross-faded: still asking, nothing to do, and the
|
||
// queue itself. The middle one used to cover the first, and the
|
||
// hand-off between any two of them used to be a single frame.
|
||
SmoothSwap(
|
||
child: _picked.isEmpty && !_firstLoadDone
|
||
? const SkeletonList(key: ValueKey('bookings-loading'))
|
||
: _picked.isEmpty && _fetchFailed
|
||
? ErrorRetry(
|
||
key: const ValueKey('bookings-error'),
|
||
onRetry: () => _fetchPicked(force: true),
|
||
)
|
||
: _picked.isEmpty
|
||
? SingleChildScrollView(
|
||
key: const ValueKey('bookings-empty'),
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
child: SizedBox(
|
||
height: MediaQuery.of(context).size.height * 0.72,
|
||
// ── The artwork is the whole empty state ──
|
||
//
|
||
// This was a [MilerEmptyState] drawing a headline and a
|
||
// body line above the illustration — and the
|
||
// illustration has both baked into it, in the same
|
||
// words: "You're all caught up", then the line about
|
||
// new jobs appearing automatically. Rendered together
|
||
// the screen said each sentence twice, which reads as a
|
||
// rendering fault rather than as one message. The panel
|
||
// is the picture now, at a size worth looking at, and
|
||
// it keeps its own copy.
|
||
//
|
||
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
|
||
// folds away entirely in release, so nothing ships
|
||
// underneath the art.
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 16.w,
|
||
),
|
||
child: Image.asset(
|
||
'assets/images/caught_up.png',
|
||
width: 330.w,
|
||
fit: BoxFit.contain,
|
||
errorBuilder: (_, __, ___) => Icon(
|
||
LucideIcons.truck,
|
||
size: 64.sp,
|
||
color: ColorConstants.borderStrong,
|
||
),
|
||
),
|
||
),
|
||
_fetchDiagLine(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
)
|
||
: Column(
|
||
key: const ValueKey('bookings-list'),
|
||
children: [
|
||
// No trip tabs here. Everything on this screen is already
|
||
// accepted, so the only live question is which stop is
|
||
// next — and that has one answer across the whole day.
|
||
// See [_visibleStops].
|
||
_buildTripRail(),
|
||
Expanded(
|
||
child: RefreshIndicator(
|
||
onRefresh: _fetchPicked,
|
||
color: ColorConstants.primary,
|
||
// ── An empty ROW list, not an empty stop list ──
|
||
//
|
||
// This asked `_visibleStops.isEmpty`, but the rows
|
||
// are built from a filtered subset of it. When the
|
||
// stops were all finished-but-not-yet-cleared, the
|
||
// page took the populated branch and rendered a
|
||
// `ListView` with zero children: a blank screen
|
||
// under the rail, with no explanation and nothing
|
||
// to pull on. Ask the list that is actually drawn.
|
||
child: _queueRows().isEmpty
|
||
? _buildTripTabEmptyState()
|
||
: Builder(
|
||
builder: (context) {
|
||
final rows = _queueRows();
|
||
return ListView.builder(
|
||
padding: EdgeInsets.only(
|
||
top: 2.h,
|
||
// Room for the live strip only when
|
||
// one is actually drawn — see
|
||
// [_liveOnThisScreen].
|
||
// ── Room for whichever bar is
|
||
// drawn ──
|
||
//
|
||
// A flat 80 was the live strip's
|
||
// height, and the round bar is
|
||
// taller: a caption, a primary
|
||
// button and the nav-bar inset
|
||
// under it. The last card sat
|
||
// behind it with its controls
|
||
// unreachable. Measured from the
|
||
// pieces rather than guessed, so it
|
||
// cannot drift when either grows.
|
||
// ── One measured floor ──
|
||
//
|
||
// The unreleased branch was built
|
||
// from its bar's own pieces; the
|
||
// live branch was a flat `80.h`
|
||
// that forgot the device inset
|
||
// entirely, so on a gesture-nav
|
||
// phone the last row of the run sat
|
||
// behind the banner *and* behind
|
||
// the navigation. Both surfaces now
|
||
// state their own height and the
|
||
// floor reserves whichever one is
|
||
// actually showing, plus the inset.
|
||
bottom: _bottomReservation(
|
||
context,
|
||
),
|
||
),
|
||
itemCount: rows.length,
|
||
itemBuilder: (context, index) {
|
||
final row = rows[index];
|
||
if (row.isHeading) {
|
||
return _QueueHeading(
|
||
label: row.heading!,
|
||
count: row.count,
|
||
// On the first heading only:
|
||
// it describes the whole
|
||
// queue, not the section.
|
||
order: index == 0
|
||
? _orderSource
|
||
: null,
|
||
);
|
||
}
|
||
|
||
final item = row.stop!;
|
||
final orderId =
|
||
(item['orderid'] ?? '')
|
||
.toString();
|
||
// Live metres + speed drive the
|
||
// distance / ETA on every density;
|
||
// RouteMetricsHelper picks the unit
|
||
// and falls back to an urban average
|
||
// when the GPS reading is unusable.
|
||
final distanceMeters =
|
||
RouteMetricsHelper.metersToStop(
|
||
item,
|
||
riderLat: _currentLocation
|
||
?.latitude,
|
||
riderLng: _currentLocation
|
||
?.longitude,
|
||
// The leg decides the end:
|
||
// a delivery row measures
|
||
// to the DOOR. It measured
|
||
// to the kitchen, so the
|
||
// figure grew as the rider
|
||
// approached the customer.
|
||
toDrop: MilkRun.workingKind(
|
||
item,
|
||
collectedIds:
|
||
_collectedIds,
|
||
).isDelivery,
|
||
);
|
||
|
||
void
|
||
open() => StopDetailSheet.show(
|
||
context,
|
||
stop: item,
|
||
stopNumber: row.displayStep,
|
||
// The pairing recorded at the
|
||
// counter — same store as the
|
||
// card, so the two agree.
|
||
bag: row.bag,
|
||
// The carried set — what turns
|
||
// a raw pickup row into the
|
||
// delivery leg the sheet must
|
||
// describe.
|
||
collectedIds: _collectedIds,
|
||
riderLat:
|
||
_currentLocation?.latitude,
|
||
riderLng:
|
||
_currentLocation?.longitude,
|
||
);
|
||
|
||
// ── One card shape per density ──
|
||
//
|
||
// Not one card dimmed four ways. See
|
||
// [QueueDensity] for why the live stop
|
||
// is physically bigger rather than
|
||
// merely brighter.
|
||
final Widget child = switch (row
|
||
.density) {
|
||
QueueDensity.now => PickupCard(
|
||
key: ValueKey(
|
||
'pickup_$orderId',
|
||
),
|
||
item: item,
|
||
displayStep: row.displayStep,
|
||
distanceMeters:
|
||
distanceMeters,
|
||
riderSpeedMps:
|
||
_currentLocation?.speed,
|
||
bag: row.bag,
|
||
collectedIds: _collectedIds,
|
||
outForDeliveryIds:
|
||
_outForDeliveryIds,
|
||
),
|
||
QueueDensity.next => _NextCard(
|
||
item: item,
|
||
bag: row.bag,
|
||
distanceMeters:
|
||
distanceMeters,
|
||
riderSpeedMps:
|
||
_currentLocation?.speed,
|
||
onOpen: open,
|
||
),
|
||
QueueDensity.later => _LaterRow(
|
||
item: item,
|
||
bag: row.bag,
|
||
distanceMeters:
|
||
distanceMeters,
|
||
onOpen: open,
|
||
),
|
||
QueueDensity.skipped =>
|
||
_SkippedRow(
|
||
item: item,
|
||
bag: row.bag,
|
||
reason: row.reason,
|
||
onOpen: open,
|
||
onResume: () =>
|
||
_resumeSkipped(item),
|
||
),
|
||
};
|
||
|
||
return Reveal(
|
||
key: ValueKey(
|
||
'reveal_$orderId',
|
||
),
|
||
delay: staggerDelay(index),
|
||
child: child,
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// The live strip, shared with Home. It places itself clear of the
|
||
// floating nav bar, so it is pinned flush here and left to it.
|
||
//
|
||
// Only for a stop this screen can actually work. On a kitchen line
|
||
// a collection is Home's, in bulk, so a strip here offering to
|
||
// "continue" one was an invitation into a flow the rider is not
|
||
// supposed to be in — and the way most riders found it. See
|
||
// [_liveOnThisScreen].
|
||
// ── The hinge of the day, at the foot of the tab that holds the
|
||
// load ──
|
||
//
|
||
// It shares this slot with the live strip and yields to it: once a
|
||
// stop is running, "continue that stop" is the only thing the rider
|
||
// wants from the bottom of this screen, and a second bar under his
|
||
// thumb offering to start something else is how the wrong one gets
|
||
// pressed. See [startRound].
|
||
// ── One contextual slot, and the workflow advances through it ──
|
||
//
|
||
// These were two `if`s over the same corner. Logically exclusive
|
||
// already — a live stop suppresses the start bar, because a
|
||
// second bar under the rider's thumb offering to start something
|
||
// else is how the wrong one gets pressed — but they *swapped* by
|
||
// one disappearing and the other appearing in the same frame,
|
||
// which reads as the screen glitching rather than as **Start
|
||
// delivery → Continue this stop**.
|
||
//
|
||
// One switcher, one child at a time, on the app's own state
|
||
// duration. Keyed by which surface it is rather than by its
|
||
// contents, so a poll that returns the same live stop does not
|
||
// replay the transition.
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: AnimatedSwitcher(
|
||
duration: DesignConstants.motionState,
|
||
switchInCurve: Curves.easeOutCubic,
|
||
switchOutCurve: Curves.easeOutCubic,
|
||
transitionBuilder: (child, anim) => FadeTransition(
|
||
opacity: anim,
|
||
child: SlideTransition(
|
||
position: Tween<Offset>(
|
||
begin: const Offset(0, 0.12),
|
||
end: Offset.zero,
|
||
).animate(anim),
|
||
child: child,
|
||
),
|
||
),
|
||
child: _liveOnThisScreen.isNotEmpty
|
||
? ActivePickupBanner(
|
||
key: const ValueKey('live'),
|
||
activePickups: _liveOnThisScreen,
|
||
onTap: (pickup) async {
|
||
await startPickup(pickup);
|
||
// Refresh after returning from map screen
|
||
if (mounted) {
|
||
await Future.delayed(
|
||
const Duration(milliseconds: 500),
|
||
);
|
||
_fetchPicked();
|
||
}
|
||
},
|
||
)
|
||
: _unreleased.isNotEmpty
|
||
? StartRoundBar(
|
||
key: const ValueKey('start-round'),
|
||
count: _unreleased.length,
|
||
busy: _starting,
|
||
onPressed: _startWholeRound,
|
||
)
|
||
: const SizedBox.shrink(key: ValueKey('none')),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// START DELIVERY — one press, the whole load
|
||
///
|
||
/// The bar the rider hits after the last counter. It states what it is about to
|
||
/// act on — `5 orders in hand` — because the press releases every one of them,
|
||
/// and a control acting on a set the rider cannot see is one he learns not to
|
||
/// trust.
|
||
///
|
||
/// ── Not a slide ──
|
||
///
|
||
/// Slides are reserved for the writes a resting thumb must not be able to make:
|
||
/// "picked" is the rider asserting he is holding somebody's lunch, "delivered"
|
||
/// that he has handed it over. Setting off is neither irreversible nor a claim
|
||
/// about somebody else's property — pressing it early costs a hub board that
|
||
/// reads *active* a few minutes early — so it is a button, at the size of every
|
||
/// other primary action in the app.
|
||
///
|
||
/// It sits above the floating nav bar rather than under it: [BottomPage] owns
|
||
/// that inset, and using it here is what keeps the button clear of the tab
|
||
/// strip on a tall phone.
|
||
/// How much scroll extent the queue must leave under itself.
|
||
///
|
||
/// ── Why this is a function and not a number ──
|
||
///
|
||
/// It was two branches and a magic constant: the unreleased case was built
|
||
/// from its bar's own pieces, and the live case was a flat `80.h` that forgot
|
||
/// the device inset entirely — so on a gesture-navigation phone the last row of
|
||
/// the run sat behind the banner *and* behind the navigation, with its controls
|
||
/// unreachable and no way to scroll to them.
|
||
///
|
||
/// One rule, reading the same two conditions the slot itself reads, so the
|
||
/// floor and the surface can never disagree about which one is on screen. Each
|
||
/// surface states its own height, because the thing that overlays a list is the
|
||
/// only thing that knows how much of the list it covers.
|
||
double bottomReservationFor({
|
||
required double inset,
|
||
required bool live,
|
||
required bool unreleased,
|
||
double idle = 8,
|
||
}) {
|
||
if (live) return inset + ActivePickupBanner.maxHeight;
|
||
if (unreleased) return inset + StartRoundBar.maxHeight;
|
||
return inset + idle;
|
||
}
|
||
|
||
class StartRoundBar extends StatelessWidget {
|
||
/// The tallest this bar gets, excluding the device inset. See
|
||
/// [ActivePickupBanner.maxHeight] for why the surface declares its own.
|
||
static double get maxHeight => ButtonSizes.primary + 54.h;
|
||
|
||
final int count;
|
||
final bool busy;
|
||
final VoidCallback onPressed;
|
||
|
||
const StartRoundBar({
|
||
super.key,
|
||
required this.count,
|
||
required this.busy,
|
||
required this.onPressed,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: EdgeInsets.fromLTRB(
|
||
16.w,
|
||
12.h,
|
||
16.w,
|
||
BottomPage.bottomInset(context) + 10.h,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.pureSurface,
|
||
boxShadow: [
|
||
// The list scrolls under this, so the edge has to read as an edge — a
|
||
// shadow rather than a rule, for the same reason the route cards have
|
||
// one: a hairline is not there on a scratched screen in sun, and a
|
||
// rule across a scrolling list reads as a divider between two rows.
|
||
BoxShadow(
|
||
color: ColorConstants.slateText.withValues(alpha: 0.10),
|
||
blurRadius: 18,
|
||
offset: const Offset(0, -4),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'$count ${count == 1 ? 'order' : 'orders'} in hand',
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
MilerButton(
|
||
label: 'Start delivery',
|
||
icon: LucideIcons.truck,
|
||
color: ColorConstants.primary,
|
||
loading: busy,
|
||
onPressed: busy ? null : onPressed,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|