// ignore_for_file: unused_element, unused_import import 'package:animated_toggle_switch/animated_toggle_switch.dart'; import 'package:another_flushbar/flushbar.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:lottie/lottie.dart' hide Marker; import 'dart:convert'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'dart:async'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:get/get.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:miler/providers/pickup/pickup_provider.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/views/Dashboard/orders/orderstaus_button.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/helpers/poll_policy.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/controllers/riderlog.dart'; import 'package:miler/controllers/logcontroller.dart'; import 'package:miler/controllers/duty_controller.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/controllers/pickup.dart'; import 'package:miler/controllers/pickups_controller.dart'; import 'package:miler/controllers/summary_controller.dart'; import 'package:geolocator/geolocator.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:geocoding/geocoding.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:floating/floating.dart'; import 'dart:math' as math; import 'package:http/http.dart' as http; import 'package:miler/views/Dashboard/home/accepted_pill.dart'; import 'package:miler/views/Dashboard/home/selection_bar.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; import 'package:miler/views/Dashboard/home/home_header.dart'; import 'package:miler/views/Dashboard/home/trip_tabs.dart'; import 'package:miler/views/Dashboard/home/trip_empty_state.dart'; import 'package:miler/views/Dashboard/home/duty_sheet.dart'; import 'package:miler/views/Dashboard/profile/Profilepage.dart'; import 'package:miler/views/Dashboard/pickups/pickups.dart' as pickups; import 'package:miler/background/live_tracking_service.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/heartbeat.dart'; import 'package:miler/data/consignment_state.dart'; import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/widget/Bottom_page.dart'; import 'package:miler/data/order_manifest.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/work_domain.dart'; import 'package:miler/data/work_repository.dart'; import 'package:miler/data/pickup_locations.dart'; import 'package:miler/data/service_day.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; import 'package:miler/views/Dashboard/home/route_timeline.dart'; import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; /// Helper function to launch phone dialer Future launchPhoneDialer(String phoneNumber) async { try { final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), ''); if (phone.isEmpty) { debugPrint('[PHONE] Empty phone number after sanitization'); return false; } final Uri uri = Uri(scheme: 'tel', path: phone); try { final launched = await launchUrl(uri, mode: LaunchMode.platformDefault); if (launched) { debugPrint('[PHONE] Successfully launched dialer for: $phone'); return true; } } catch (e) { debugPrint('[PHONE] Failed to launch dialer: $e'); } return false; } catch (e) { debugPrint('[PHONE] Error in launchPhoneDialer: $e'); return false; } } class Homepage extends StatefulWidget { const Homepage({super.key}); @override State createState() => _HomepageState(); } class _HomepageState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { // ==================== MILER BRAND PALETTE ==================== // Aligned to the shared design tokens (ColorConstants) so Home matches // the rest of the app. static final Color _primaryRed = ColorConstants.primary; // #960019 static Color get _darkRed => ColorConstants.primary; static Color get _lightRed => ColorConstants.errorLight; static final Color _redAccent = ColorConstants.primaryContainer; // #C01227 static Color get _successColor => ColorConstants.success; static Color get _warningColor => ColorConstants.warning; // Slate-tinted daylight grey (#F8FAFC), not the old warm #FCF9F8. The warm // surface sat ~3% from white, so white cards on it had no visible edge in // sunlight — riders reported not being able to tell cards apart. /// ── Home's ground, on the Narrative ladder ── /// /// It read `daylightSurface` — the heavy slate that was right when a card was /// a bare white fill and the tonal step was the only thing separating the /// two. Cards carry a hairline and a lift now, so the ground is the brief's /// light canvas and the two halves of the trade are in the same place. One /// alias, so Home cannot drift from the other three tabs again. /// ── The page is white ── /// /// It was [MilerSurface.canvas] — #DEE3EA — chosen when Home's rows sat /// directly on the page and needed a ground to separate from. They do not any /// more: the run brief and every stop are cards with their own edge and their /// own lift, so the grey was a second separator under things that already had /// one, and it made a screen of white cards read as heavy. /// /// White, and the cards do the separating. It is also what the brand red /// needs to look like an accent rather than a fourth surface tone. static Color get _surfaceColor => ColorConstants.pureSurface; static Color get _cardColor => ColorConstants.pureSurface; static Color _textPrimary = ColorConstants.onSurface; // app-standard near-black static Color get _textSecondary => ColorConstants.onSurfaceVariant; static final Color _textTertiary = ColorConstants.secondaryText; // #8E8E93 // ==================== STATE VARIABLES ==================== bool isOnline = true; String _userName = ''; String _city = ''; String _shiftStart = ''; String _shiftEnd = ''; int _pickupsToday = 0; final PickupProvider _pickupProvider = PickupProvider(); final PickupController _pickupController = Get.find(); Map> _ordersMap = {}; List _orderIds = []; StreamSubscription? _pollerSubscription; Duration? _currentPollInterval = const Duration(seconds: 0); bool _isToggling = false; Map _selectedOrders = {}; bool isAllSelected = false; String _lastQueuesJson = ''; bool _loadedOnlineFromPrefs = false; bool _isFetchingQueues = false; /// False until the first queue fetch has returned. /// /// Home is trip-shaped, and "no trips" renders as "Trip 1 not assigned yet" — /// a definite statement about the rider's day. On a cold open it was made /// before the request had come back, so the landing screen of the app told /// every rider his hub had given him nothing, then quietly replaced it with /// his route. Not knowing yet gets its own state now. bool _queuesLoaded = false; /// False while the app is backgrounded. Polling a screen nobody is looking /// at spends the rider's battery on information he cannot see. bool _pollForeground = true; /// Set when a fetch fails and we have nothing to show. Without it an empty /// list after a network error rendered as "Trip 1 not assigned yet" — the app /// reporting its own failure to the rider as good news about his workload. bool _fetchFailed = false; final Map _pickupRunning = {}; final Map _pickupTimers = {}; final Map> _pickupBasePayload = >{}; final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider(); final Map _statusBusy = {}; Timer? _locationTimer; final Set _hiddenOrderIds = {}; List> _activePickups = >[]; static final http.Client _httpClient = http.Client(); Position? _lastGeocodedPosition; /// Where the rider currently is. Stands in for the hub he set out from when /// measuring the round-trip length of the remaining route. double? _riderLat; double? _riderLng; /// Stops the rider has accepted (now on the Bookings tab) — off this list, /// but still part of the route the header summarises. List> _acceptedStops = >[]; /// Id of the trip currently being accepted, so only that card shows a /// spinner instead of every card on the screen. String? _acceptingTripId; /// Order ids with a single-stop action in flight. final Set _busyStopIds = {}; /// Every stop the hub assigned today, in any state — the source the trip /// cards are built from. See the note in `_fetchQueues`. List> _allTripStops = >[]; /// Locally recorded decisions. Optimistic: the rider's tap lands here /// immediately so a slow network can never bounce a stop back to pending /// under his thumb. Set _acceptedIds = {}; /// Orders the rider is physically carrying, on a service run. /// /// Local only — the parcel backend has no state between "accepted" and /// "picked", and `picked` is the delivery. See the collected store. Set _collectedIds = {}; /// `next_action` per order, as the pivot returned it. Home reads it only to /// count what is waiting on the other tab — the two screens must hand /// [WorkBoundary] the same arguments or their counts drift apart. Map _pivotActions = {}; /// Orders the rider has written off today — delivered, cancelled or skipped. /// /// The union of the completed and skipped stores, both of which record a /// mutation only after it came back OK. Held here so Home's counters can ask /// [WorkBoundary] the same question the Deliveries tab asks, with the same /// arguments. See [_acceptedWaitingCount]. Set _closedIds = {}; Set _rejectedIds = {}; /// Notices when the day underneath the screen has moved on. /// /// [_fetchQueues] filters against `ServiceDay.today` *as read at fetch time*, /// so the route on screen is only ever as fresh as the last fetch. See /// [_rolloverCheck]. final ServiceDayRollover _day = ServiceDayRollover(); /// Fires at the next local midnight. See [_armRollover]. Timer? _rollover; /// Which trip tab is open. int _selectedTripIndex = 0; /// True once the rider has picked a tab himself. See [_settleTripTab]. bool _tripTabChosen = false; /// Stops the rider has ticked, for "accept just these". Set _selectedStopIds = {}; // ==================== LIFECYCLE METHODS ==================== @override void dispose() { WidgetsBinding.instance.removeObserver(this); BottomPage.currentTab.removeListener(_onTabChanged); final keys = List.from(_pickupTimers.keys); for (final k in keys) { _stopPickupPosting(k); } _pollerSubscription?.cancel(); _locationTimer?.cancel(); _rollover?.cancel(); _httpClient.close(); super.dispose(); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _loadName(); _loadPickupsToday(); _ensureLocationPermission(); _ensureCameraPermission(); _startPolling(); _armRollover(); // What the counters are actually called, joined on the location id the // booking already carries. One request, cached, and every failure resolves // to "use the row's own name" — see [PickupLocations]. unawaited(PickupLocations.ensureLoaded()); // The building the logistics run is headed by — see [RiderHub]. Read from // prefs the login wrote, so it costs nothing and cannot fail the fetch. unawaited(RiderHub.ensureLoaded()); BottomPage.currentTab.addListener(_onTabChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _fetchQueues(); }); try { if (Get.isRegistered()) { final pc = Get.find(); ever(pc.userName, (_) { _loadName(); }); } } catch (_) {} } @override bool get wantKeepAlive => true; // ==================== HELPER METHODS ==================== void _stopPickupPosting(String orderId) { _pickupTimers[orderId]?.cancel(); _pickupTimers.remove(orderId); } String _getStatusFromOrder(Map Booking) { final apiStatus = (Booking['orderstatus']?.toString().toLowerCase() ?? '') .trim(); if (apiStatus == 'accepted') return 'ACCEPTED'; if (apiStatus == 'arrived') return 'ARRIVED'; if (apiStatus == 'picked') return 'PICKED'; return 'ACCEPT'; } double _parseDouble(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } String _getTimeGreeting() { final hour = DateTime.now().hour; if (hour < 12) return "Morning"; if (hour < 17) return "Afternoon"; return "Evening"; } bool _hasActivePickup() { return _activePickups.isNotEmpty; } // ==================== LOCATION METHODS ==================== /// The error radius, in metres, of the fix [_getValidCoordinates] last took. /// Credited to the rider by [_isNearPickupLocation], the same way /// `PickupsController._checkGeofence` credits its own. double _lastFixAccuracy = 0; /// ── `low` was fine for telemetry and is not fine for a 10 m fence ── /// /// This asked for [LocationAccuracy.low] — a ~1 km hint on Android — and /// fell back to a last-known position of any age. The pair it returns is /// then used for two different jobs: it rides along on the status payload, /// where a rough fix costs nothing, and it is measured against the geofence, /// where a rough fix is not a measurement at all. /// /// So it takes a real one while the fence is enforced, and keeps the cheap /// path when it is not. A cached fix is accepted only if it is fresher than /// [kGeofenceFixMaxAge] — on a round, a stale one is reliably the previous /// stop. Future<(String lat, String lng)?> _getValidCoordinates() async { try { await _ensureLocationPermission(); Position? pos; try { pos = await Geolocator.getCurrentPosition( locationSettings: LocationSettings( accuracy: kGeofenceEnforced ? LocationAccuracy.best : LocationAccuracy.low, timeLimit: Duration(seconds: kGeofenceEnforced ? 8 : 3), ), ); } catch (_) { final cached = await Geolocator.getLastKnownPosition(); final stale = cached != null && kGeofenceEnforced && DateTime.now().difference(cached.timestamp) > kGeofenceFixMaxAge; pos = stale ? null : cached; } _lastFixAccuracy = pos?.accuracy ?? 0; if (pos != null) { final lat = pos.latitude.toStringAsFixed(6); final lng = pos.longitude.toStringAsFixed(6); try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('last_lat', lat); await prefs.setString('last_lng', lng); } catch (_) {} return (lat, lng); } 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') { return (lat, lng); } } catch (_) {} return null; } catch (_) { return null; } } Future _ensureLocationPermission() async { try { final prefs = await SharedPreferences.getInstance(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { final lastPos = await Geolocator.getLastKnownPosition(); if (lastPos != null) { await prefs.setString('last_lat', lastPos.latitude.toString()); await prefs.setString('last_lng', lastPos.longitude.toString()); } Geolocator.openLocationSettings(); return; } LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) { final lastPos = await Geolocator.getLastKnownPosition(); if (lastPos != null) { await prefs.setString('last_lat', lastPos.latitude.toString()); await prefs.setString('last_lng', lastPos.longitude.toString()); } return; } Position? pos; try { pos = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.medium, timeLimit: const Duration(seconds: 5), ).timeout(const Duration(seconds: 5)); } catch (e) { debugPrint('[HOMEPAGE] Timeout getting location: $e'); pos = await Geolocator.getLastKnownPosition(); } if (pos != null) { await prefs.setString('last_lat', pos.latitude.toString()); await prefs.setString('last_lng', pos.longitude.toString()); } } catch (e) { debugPrint('[HOMEPAGE] Error ensuring location permission: $e'); try { final lastPos = await Geolocator.getLastKnownPosition(); if (lastPos != null) { final prefs = await SharedPreferences.getInstance(); await prefs.setString('last_lat', lastPos.latitude.toString()); await prefs.setString('last_lng', lastPos.longitude.toString()); } } catch (_) {} } } Future _ensureCameraPermission() async { try { final status = await Permission.camera.status; if (status.isDenied) { await Permission.camera.request(); } } catch (e) { debugPrint('[HOMEPAGE] Error ensuring camera permission: $e'); } } void _startLocationUpdates() { _locationTimer?.cancel(); _locationTimer = Timer.periodic(const Duration(seconds: 30), (_) { _getAndUpdateCurrentLocation(); }); } Future _getAndUpdateCurrentLocation() async { bool serviceEnabled; LocationPermission permission; serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { _showLocationBottomSheet(); return; } permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { return; } } if (permission == LocationPermission.deniedForever) { return; } Position position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); if (_lastGeocodedPosition != null) { final dist = Geolocator.distanceBetween( _lastGeocodedPosition!.latitude, _lastGeocodedPosition!.longitude, position.latitude, position.longitude, ); if (dist < 100) return; } try { List placemarks = await placemarkFromCoordinates( position.latitude, position.longitude, ); if (placemarks.isNotEmpty) { _lastGeocodedPosition = position; Placemark place = placemarks.first; String fullAddress = "${place.name}, ${place.subLocality}, ${place.locality}, ${place.administrativeArea}, ${place.country}, ${place.postalCode}"; String city = place.locality ?? ''; String state = place.administrativeArea ?? ''; String suburb = place.subLocality ?? ''; if (mounted && city.isNotEmpty && city != _city) { setState(() => _city = city); } await _updateProfile( address: fullAddress, latitude: position.latitude, longitude: position.longitude, city: city, state: state, suburb: suburb, ); } } catch (e) { debugPrint("Error geocoding location: $e"); } } Future _updateProfile({ required String address, required double latitude, required double longitude, required String city, required String state, required String suburb, }) async { debugPrint('Profile update → $address | $latitude,$longitude'); } void _showLocationBottomSheet() { // ── Through the sheet kit ── // // This drew a plain white sheet with its own 24 radius, an oversized 64pt // icon in a filled disc, and a Close IconButton row — the one sheet in the // app with an X, duplicating both the barrier tap and the drag the kit's // handle advertises. The header idiom is the same one every other sheet // now uses; the icon is the standard badge size, and the drag handle plus // the barrier are how a sheet closes. showMilerSheet( context, builder: (ctx) => MilerSheetScaffold( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const MilerSheetHeader( title: 'Turn on location', subtitle: 'Bookings and live tracking need your position. Nothing is ' 'assigned to a rider your office cannot see.', icon: LucideIcons.mapPinOff, ), SizedBox(height: 20.h), MilerButton( label: 'Enable location', onPressed: () async { Navigator.of(ctx).pop(); await Geolocator.openLocationSettings(); }, ), ], ), ), ); } // ==================== PROXIMITY METHODS ==================== Future _isNearPickupLocation(Map Booking) async { // ── The second proximity gate ── // // `PickupsController._checkGeofence` guards a single stop. This one guards // the *bulk* "mark selected as arrived" action on Home, and it runs before // the controller is ever called. // // It honours the same switch and, since this change, the same **radius**. // It was hardcoded to 500 m while the controller read a configured 100 — // so the app had two fences of different sizes on two routes into the same // rung, and which one a rider met depended on whether he had ticked boxes // or slid a sheet. One number now: [kGeofenceRadiusMeters]. if (kBypassGeofenceForTesting) { debugPrint( '[GEOFENCE] OFF for bulk arrival — enforcement is disabled in this ' 'build. Stop completion is NOT proximity-verified. ' 'See kGeofenceEnforced.', ); return true; } try { final pickupLat = _parseDouble( Booking['pickuplat'] ?? Booking['PickupLat'], ); final pickupLng = _parseDouble( Booking['pickuplon'] ?? Booking['PickupLon'], ); if (pickupLat == 0 || pickupLng == 0) return true; final riderLoc = await _getValidCoordinates(); if (riderLoc == null) return true; final double riderLat = double.tryParse(riderLoc.$1) ?? 0; final double riderLng = double.tryParse(riderLoc.$2) ?? 0; if (riderLat == 0 || riderLng == 0) return true; final double distanceInMeters = Geolocator.distanceBetween( riderLat, riderLng, pickupLat, pickupLng, ); // The phone's own error is credited to the rider here too — see // [kGeofenceRadiusMeters] for why a 10 m fence measured with a ±20 m fix // has to, and `_getValidCoordinates` for the fix it is measured with. final double slack = _lastFixAccuracy; return (distanceInMeters - slack) <= kGeofenceRadiusMeters; } catch (e) { debugPrint('[PROXIMITY] Error checking proximity: $e'); return true; } } Future> _checkProximityForOrders( List orderIds, ) async { final Map results = {}; for (final orderId in orderIds) { final Booking = _ordersMap[orderId]; results[orderId] = Booking != null ? await _isNearPickupLocation(Booking) : false; } return results; } Future _showProximityWarning( BuildContext context, { String? specificMessage, }) async { return showAppDialog( context: context, barrierDismissible: true, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), title: Row( children: [ Icon(LucideIcons.mapPinOff, color: _primaryRed, size: 28), const SizedBox(width: 12), Expanded( child: Text( 'Not at Pickup Location', style: TextStyle( fontSize: FontConstants.xLarge(context), fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), ), ), ], ), content: Text( specificMessage ?? 'You must be within ' '${kGeofenceRadiusMeters.toStringAsFixed(0)} metres of the ' 'pickup location to mark this booking as arrived.', style: TextStyle( fontSize: FontConstants.regular(context), fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), style: TextButton.styleFrom(foregroundColor: _primaryRed), child: Text( 'OK', style: TextStyle( fontSize: FontConstants.regular(context), fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } // ==================== ONLINE/OFFLINE TOGGLE ==================== // // The sheet itself is [DutySheet] — chrome, copy and the slide control. What // stays here is the work: the pending-booking gate, the API call and the // polling and tracking that follow it. void _confirmOnlineOffline() { if (_isToggling) return; final bool newStatus = !isOnline; if (!mounted) return; DutySheet.show( context, goingOnline: newStatus, onConfirm: () => _applyDutyChange(newStatus), ); } /// Carries out the duty change the sheet just asked for. /// /// Returns true when duty actually changed — the sheet uses that to decide /// whether to close or to spring its knob back and stay put. Future _applyDutyChange(bool newStatus) async { if (!mounted || _isToggling) return false; // ── The one thing that can refuse ── // // A rider cannot walk away from work he is holding. This is checked here // rather than by disabling the switch, because the answer depends on a // stored flag that is cheaper to read on demand than to watch. if (newStatus == false) { bool hasPending = _ordersMap.isNotEmpty || _pickupRunning.values.any((v) => v); try { final prefs = await SharedPreferences.getInstance(); hasPending = hasPending || (prefs.getBool('has_live_pickup') ?? false); } catch (_) {} if (hasPending) { if (!mounted) return false; try { await showAppDialog( context: context, builder: (dCtx) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), title: Text( 'Finish your bookings first', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textPrimary, fontWeight: FontWeight.w700, ), ), content: Text( 'You are still holding work. Complete or hand back your open ' 'bookings, then you can go off duty.', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), actions: [ TextButton( onPressed: () => Navigator.of(dCtx).pop(), child: Text( 'OK', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _primaryRed, ), ), ), ], ), ); } catch (_) {} return false; } } if (!mounted) return false; _isToggling = true; showAppDialog( context: context, barrierDismissible: false, builder: (dialogCtx) => Center( child: Container( padding: const EdgeInsets.all(22), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: SizedBox( height: 40, width: 40, child: CircularProgressIndicator( strokeWidth: 3.2, valueColor: AlwaysStoppedAnimation(_primaryRed), ), ), ), ), ); bool ok = false; try { ok = await _handleOnlineToggle( newStatus, ).timeout(const Duration(seconds: 30)); } catch (_) { ok = false; } finally { if (mounted && Navigator.of(context).canPop()) { Navigator.of(context).pop(); } _isToggling = false; } if (!mounted) return false; setState(() { isOnline = ok ? newStatus : isOnline; }); // Broadcast through the single source of truth so the pickups gate + every // screen agree instantly. try { await DutyController.to.setOnlineLocal(isOnline); } catch (_) {} // ── Say what happened, not "Offline" ── // // This line was `successGlobal('Offline')`, unconditionally: it congratulated // the rider on going offline when he had just come *on* duty, and said the // same thing when the call had failed and his duty had not changed at all. if (mounted) { if (ok) { AppFeedback.successGlobal( newStatus ? "You're on duty" : "You're off duty", ); } else { AppFeedback.errorGlobal("Couldn't change your duty — try again"); } } if (ok && newStatus) { _startLocationUpdates(); LiveTrackingService().startTracking(); _fetchQueues(); _startPolling(); } else if (ok && !newStatus) { _locationTimer?.cancel(); LiveTrackingService().stopTracking(); _fetchQueues(); _startPolling(); } return ok; } Future _handleOnlineToggle(bool online) async { try { if (!Get.isRegistered()) return false; final ctl = Get.find(); final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userId') ?? prefs.getInt('userid'); if (userId == null) return false; const String lat = '0'; const String lng = '0'; if (online) { debugPrint('[BREAK] Ending break...'); final ok = await ctl .endBreakAuto(latitude: lat, longitude: lng) .timeout(const Duration(seconds: 12), onTimeout: () => false); debugPrint('[BREAK] endBreakAuto -> $ok'); final dutyOk = await ctl.setOnDuty(true); debugPrint('[ONDUTY] setOnDuty(true) -> $dutyOk'); await prefs.setBool('online', dutyOk); } else { debugPrint('[BREAK] Starting break...'); final ok = await ctl .startBreakAuto(latitude: lat, longitude: lng) .timeout(const Duration(seconds: 12), onTimeout: () => false); debugPrint('[BREAK] startBreakAuto -> $ok'); final dutyOk = await ctl.setOnDuty(false); debugPrint('[ONDUTY] setOnDuty(false) -> $dutyOk'); await prefs.setBool('online', dutyOk ? false : true); } return true; } catch (_) { return false; } } // ==================== DATA FETCHING ==================== Future _loadName() async { String name = ''; try { if (Get.isRegistered()) { final pc = Get.find(); final val = pc.userName.value; if (val.toString().trim().isNotEmpty) { name = val.toString().trim(); } } } catch (_) {} try { final prefs = await SharedPreferences.getInstance(); if (name.isEmpty) { name = (prefs.getString('user_name') ?? '').trim(); } _shiftStart = (prefs.getString('starttime') ?? '').toString(); _shiftEnd = (prefs.getString('endtime') ?? '').toString(); // Last fix written by _getValidCoordinates / the location loop — the // origin for the route-length estimate in the header. _riderLat = _lastGeocodedPosition?.latitude ?? double.tryParse(prefs.getString('last_lat') ?? ''); _riderLng = _lastGeocodedPosition?.longitude ?? double.tryParse(prefs.getString('last_lng') ?? ''); // Duty state now resolves through the single DutyController (correct // priority lives there), so the gate agrees across every screen. final bool online = await DutyController.to.load(); if (!mounted) return; setState(() { _userName = name; if (!_loadedOnlineFromPrefs) { isOnline = online; if (isOnline) { try { if (Get.isRegistered()) { Get.find().startLogging(); } } catch (_) {} } _loadedOnlineFromPrefs = true; } }); } catch (_) { if (!mounted) return; setState(() { _userName = name; isOnline = true; }); } } /// Loads today's completed-pickup count from the shared SummaryController /// so the hero shows a real metric instead of a hardcoded placeholder. Future _loadPickupsToday() async { try { final controller = Get.isRegistered() ? Get.find() : Get.put(SummaryController()); final prefs = await SharedPreferences.getInstance(); final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; if (uid > 0) { await controller.fetchSummaryStats(uid); } if (!mounted) return; setState(() => _pickupsToday = controller.today.value); } catch (_) {} } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); // Before the early return below: a resume inside the same foreground state // still has to notice a midnight that passed while the phone was in the // rider's pocket, and that is the case the timer cannot cover — both // platforms suspend timers in the background. if (state == AppLifecycleState.resumed) _rolloverCheck(); final bool foreground = state == AppLifecycleState.resumed; if (foreground == _pollForeground) return; _pollForeground = foreground; if (foreground) { // Catch up once on the way back in, then resume the normal cadence. _fetchQueues(); _startPolling(); } else { _startPolling(); } } /// Home's index in the bottom bar. static const int _homeTabIndex = 0; /// Re-reads the day the moment Home becomes the visible tab. /// /// ── Why a 30-second poll was not good enough ── /// /// Home polls on the shared interval — 30s waiting for the hub, 60s off duty /// — which is right for work arriving *from* the hub and wrong for work the /// rider just finished with his own hands on the next tab along. He /// completed a delivery, watched Deliveries go 22 → 21, tapped Home, and the /// pill said 22 for up to another half-minute. /// /// Deliveries and Activity have both listened for this for a while /// (`MyPickups._onTabChanged`, `ActivityPage`); Home was the one screen that /// did not, which is why it was also the one screen showing a stale figure. void _onTabChanged() { if (BottomPage.currentTab.value == _homeTabIndex && mounted && !_isFetchingQueues) { _fetchQueues(); } } void _startPolling() { if (!_pollForeground) { _pollerSubscription?.cancel(); _pollerSubscription = null; _currentPollInterval = null; return; } // 5s while a stop is live, 30s waiting for the hub, 60s off duty — the // shared policy, so Home and Bookings cannot disagree about how hard they // work the rider's battery. See [pollInterval]. final interval = pollInterval( hasLiveWork: _activePickups.isNotEmpty, onDuty: isOnline, ); if (_pollerSubscription != null && _currentPollInterval == interval) { return; } _pollerSubscription?.cancel(); _currentPollInterval = interval; _pollerSubscription = Stream.periodic(interval, (_) {}) .asyncMap((_) async { if (mounted && !_isFetchingQueues) { await _fetchQueues(); } }) .listen( (_) {}, onError: (error) { debugPrint('[HOMEPAGE][STREAM ERROR] $error'); }, cancelOnError: false, ); } /// Calls a stop's customer, and says so when the dialer will not open. Future _callStop(Map stop) async { final phone = (stop['pickupcontactno'] ?? '').toString(); final ok = await launchPhoneDialer(phone); if (!ok && mounted) { AppFeedback.error( context, phone.trim().isEmpty ? 'No phone number on this stop' : "Couldn't open the dialer — $phone", ); } } /// Pull-to-refresh on Home. /// /// Deliberately not `onRefresh: _fetchQueues`, for two reasons. /// /// **The background poll gets in the way.** [_fetchQueues] self-guards with /// [_isFetchingQueues] and returns *immediately* when a fetch is already /// running — and with a 5s poll that is close to a coin flip on any given /// pull. Handing that future straight to the indicator would dismiss the /// spinner the moment the rider let go, about half the time, which looks /// exactly like a refresh that failed. So a pull waits for the in-flight /// fetch to land rather than racing it. /// /// **An instant answer still has to look like an answer.** [_fetchQueues] /// short-circuits when the payload is unchanged (`_lastQueuesJson`), so a /// refresh can finish in a few milliseconds and the spinner would blink out /// before it was ever read as spinning. The floor below is what every app the /// rider already uses does here. Future _handleRefresh() async { // Started first so the work runs *inside* the floor, not after it. // 650 → 400: still long enough to read as a real refresh rather than a // flicker, 250ms cheaper on every pull. final minimumSpin = Future.delayed(const Duration(milliseconds: 400)); try { // Wait out a poll already in flight. Bounded, so a hung request cannot // pin the spinner to the screen indefinitely. final deadline = DateTime.now().add(const Duration(seconds: 6)); while (_isFetchingQueues && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 80)); } // Name, city and the shift window come from the profile; the trips come // from the queue. He pulled to re-read the screen, not half of it. if (mounted) await _loadName(); if (mounted) await _fetchQueues(); } catch (e) { // Never rethrow into the indicator: an error out of onRefresh becomes an // unhandled framework exception, and the rider's recourse is to pull // again either way. Both calls already log their own failures. debugPrint('[REFRESH] Home refresh failed: $e'); } await minimumSpin; } Future _fetchQueues() async { if (_isFetchingQueues) return; try { _isFetchingQueues = true; final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userid'); if (userId == null) { // Not signed in — there is no route to show. The debug branch that used // to fill this with seeded demo trips is gone with the rest of the mock // layer: Home is now a window onto the hub's answer, and an empty one // means the hub said nothing. if (!mounted) return; setState(() { _ordersMap.clear(); _orderIds.clear(); _selectedOrders.clear(); isAllSelected = false; }); return; } // Keep the online/offline UI in sync for the empty-state nudge, but do NOT // hard-block here. The backend owns assignment; hiding API-returned // bookings behind a flaky local duty flag silently loses real work. The // "on-duty to start a pickup" rule is enforced (loudly) at the action via // ensureOnlineForAction — not by dropping data on the floor. final bool online = await DutyController.to.load(); if (mounted && isOnline != online) { setState(() => isOnline = online); } // Try fetching from API. On the live/new backend we show ONLY real data // (an empty queue stays empty); mock data is used solely on the old demo // path so testers still see something offline. List items; try { items = await _pickupProvider.getPickupQueues(); } catch (e) { debugPrint('[FETCH_QUEUES] API error: $e'); items = []; } if (!mounted) return; await _fetchActivePickups(userId); // Bookings the rider already accepted or rejected (persisted) must not // reappear in the Home queue, even after a refetch or coming back to the // Home tab. final acceptedIds = await getAcceptedOrderIds(); final rejectedIds = await getRejectedOrderIds(); final collectedIds = await getCollectedOrderIds(); final pivotActions = await getPivotNextActions(); // Stops parked for a return visit. They are Activity's now — see the // stamping loop below and `TripCard._showsOnHome`. final skippedIds = await getSkippedOrderIds(); // Stops he has already closed today. Same argument as the skips: the // rider's own record of what he did beats a queue that has not caught up. final completedIds = await getCompletedOrderIds(); // Kept, because the pill's count needs the same answer the Deliveries // tab builds its list from — see [_acceptedWaitingCount]. Assigned // directly rather than through `setState`: this runs inside a fetch that // already rebuilds, and the field is only ever read during that build. _closedIds = {...completedIds, ...skippedIds}; // Accepted stops have left the Home list for the Bookings tab, but they // are still on the rider's route — their parcels are still to be // collected and their kilometres still to be ridden. The route brief // must count them or it understates the day. // Through the day filter as well. The accepted store is deliberately // *not* pruned to today — it is "what have I taken on", and an id set // that forgets a stop would flip that stop back to pending under the // rider's thumb — but the route brief above the run is a statement about // **this** day's kilometres and parcels. Yesterday's accepted-and-unclosed // booking belongs in the set and not in that total. // // Only the rows are filtered; `acceptedIds` above is left whole. final acceptedStops = _todayOnly(await getAcceptedBookings()); if (mounted && acceptedStops.length != _acceptedStops.length) { setState(() => _acceptedStops = acceptedStops); } else { _acceptedStops = acceptedStops; } // EVERY stop the hub assigned today, whatever its state. // // Home used to drop a stop the moment it was accepted, which emptied the // screen out as the rider worked and made it impossible to show trip // progress. A trip is a unit: it stays on Home from assignment until the // last stop is resolved, and each stop renders its own state (pending / // accepted / active / done / rejected) via `stopStateOf`. // // Accepted stops still also appear on Bookings — that is where he WORKS // them. Home is where he watches the trip fill up. final allStops = _todayOnly( items.whereType>().toList(), ); // ── The skip store is the truth about what is parked ── // // The queue endpoints are behind the rider by up to a poll, and on demo // data they never catch up at all, so a raw `orderstatus` alone gets both // directions of a skip wrong: a stop he has just skipped still reports as // live, and one he has just resumed from Activity still reports as // 'skipped' — and `stopStateOf` reads a raw 'skipped' ahead of any local // decision, so the resume would silently undo itself within seconds. // // Stamped both ways here, from the same store Activity reads, so the // progress rail and Activity can never disagree about which stops are // parked. // ── The rider's own arrivals ── // // Read alongside the other local sets and applied with them, because // `reached` does not persist the rung: without this the queue reports // `Miler_Assigned` for a stop the rider is standing at and the row drops // back to ACCEPTED. See [getArrivedOrderIds] for the whole of it. final arrivedIds = await getArrivedOrderIds(); for (final s in allStops) { final id = (s['orderid'] ?? '').toString(); if (id.isEmpty) continue; final raw = stopStatusFromRaw(s['orderstatus']); // Finished beats everything: a stop he has closed is closed, whatever // the queue still says about it. Without this the row he just picked // up stays on Home wearing its LIVE mark until the backend catches up, // which reads as the confirm not having worked. if (completedIds.contains(id)) { s['orderstatus'] = 'picked'; } else if (skippedIds.contains(id)) { s['orderstatus'] = 'skipped'; } else if (raw == StopStatus.skipped && acceptedIds.contains(id)) { s['orderstatus'] = 'accepted'; } else if (arrivedIds.contains(id) && _localArrivalMayShow(raw)) { // Below the three above on purpose. Arrival is the *weakest* of the // local records — it is the one the server could not confirm — so // anything the server does know about this stop outranks it. s['orderstatus'] = 'arrived'; } } // Still-undecided stops only — the accept/reject actions and the "N // pending" counts operate on this narrower set. final validOrders = allStops.where((m) { final oid = (m['orderid'] ?? '').toString(); if (oid.isNotEmpty && _hiddenOrderIds.contains(oid)) return false; if (oid.isNotEmpty && acceptedIds.contains(oid)) return false; if (oid.isNotEmpty && rejectedIds.contains(oid)) return false; final st = stopStatusFromRaw(m['orderstatus']); return st != StopStatus.active && st != StopStatus.accepted && st != StopStatus.arrived && st != StopStatus.picked && st != StopStatus.cancelled && st != StopStatus.rejected && // A parked stop is not on offer. It was never rendered as a // decision row, but it was still counted as one — so "3 stops to // accept" included stops the rider had already put down. st != StopStatus.skipped; }).toList(); // Visibility into the pipeline so a "API returned N but Home shows 0" can // be diagnosed from logs (online flag, raw count, and survivors). debugPrint( '[FETCH_QUEUES] line=${ServiceProfile.active.line.name} ' 'online=$online rawFromApi=${items.length} ' 'afterFilters=${validOrders.length} ' 'hidden=${_hiddenOrderIds.length} accepted=${acceptedIds.length} ' 'rejected=${rejectedIds.length}', ); // Keyed on ALL stops plus the local decision sets: a stop moving from // accepted to done changes no pending row, but it does move the trip's // completion ring, so the card has to repaint. final newJson = jsonEncode({ 'all': allStops, 'accepted': acceptedIds.toList()..sort(), 'rejected': rejectedIds.toList()..sort(), }); if (newJson == _lastQueuesJson) { return; } _lastQueuesJson = newJson; final Map> newOrdersMap = {}; final List newOrderIds = []; for (final Booking in validOrders) { final orderId = (Booking['orderid'] ?? '').toString(); if (orderId.isEmpty) continue; newOrdersMap[orderId] = Booking; newOrderIds.add(orderId); } final Map newSelection = {}; for (final orderId in newOrderIds) { newSelection[orderId] = _selectedOrders[orderId] ?? false; } setState(() { _allTripStops = allStops; _acceptedIds = acceptedIds; _rejectedIds = rejectedIds; _collectedIds = collectedIds; _pivotActions = pivotActions; _ordersMap = newOrdersMap; _orderIds = newOrderIds; _selectedOrders = newSelection; isAllSelected = _selectedOrders.isNotEmpty && _selectedOrders.values.every((selected) => selected); for (final orderId in _orderIds) { if (!_pickupRunning.containsKey(orderId)) { _pickupRunning[orderId] = false; } } }); // After the state is applied, because the slots are derived from it. _settleTripTab(); } catch (e) { debugPrint('[FETCH_QUEUES] Error: $e'); if (mounted) setState(() => _fetchFailed = true); } finally { _isFetchingQueues = false; if (!_queuesLoaded && mounted) { setState(() => _queuesLoaded = true); } } } /// Opens Home on a tab that has work on it. /// /// ── Why this became necessary ── /// /// The tab index is a **day part**, not a position: slot 0 is the morning /// whether or not the rider has morning work. That is the right model, and /// while nothing could place a trip by day part it cost nothing — every trip /// fell into the undated bucket and landed on Trip 1, so Trip 1 was where the /// work was. /// /// Now that [Trip.dayPartTimeOf] can place them, a rider whose whole day is /// an afternoon run has his work under Trip 2 — correctly — and Home opened /// on Trip 1 and told him his hub had assigned him nothing. The fix for one /// screen must not hand him a worse version of the same lie. /// /// So the landing tab is the first slot that actually holds stops. Three /// things keep it from fighting the rider: /// /// • It gives up the moment he taps a tab himself — [_tripTabChosen]. An /// empty Trip 3 he chose deliberately is an answer he asked for. /// • It gives up the moment the tab it is on has work, so it lands once and /// then stops moving under him. /// • It never *hides* a slot. The tabs still show all three, empty ones /// included, because "the day has room left in it" is a fact he plans /// around. void _settleTripTab() { if (_tripTabChosen || !mounted) return; final trips = _trips; final here = trips.tripAt(_selectedTripIndex); if (here != null && here.stops.isNotEmpty) return; final slots = trips.slots; for (var i = 0; i < slots.length; i++) { final t = slots[i]; if (t == null || t.stops.isEmpty) continue; if (i != _selectedTripIndex) { setState(() { _selectedTripIndex = i; _selectedStopIds.clear(); }); } return; } } /// ── Home is today's work, and only today's ── /// /// The rule, and the reasoning behind every part of it, is /// [ServiceDay.onlyToday] — shared with the Deliveries queue, because the two /// screens showing the rider's live work must not disagree about which day it /// is. What is local here is only *which* day: the one the screen is /// currently showing, adopted so the midnight check below compares against /// the pixels rather than reading the wall clock twice. List> _todayOnly(List> stops) { _day.rollIfNeeded(); return ServiceDay.onlyToday(stops, now: _day.day, where: 'HOME'); } /// ── The day ending under a screen that is already open ── /// /// [ServiceDay] answers "which day is this row" at read time, which is right /// for the data and not sufficient for the screen: [_fetchQueues] filters /// against the day *as read when it ran*. A rider who leaves Home open /// through 23:59, or backgrounds the app on it and picks the phone up at six, /// is looking at a route filtered against a day that has ended. /// /// Two triggers, because a phone gives no single reliable one — a timer to /// the next local midnight for the app left in the foreground, and resume for /// every other case. Both land in [_rolloverCheck], which is idempotent. /// The same arrangement `ActivityPage` uses, for the same reason. void _armRollover() { _rollover?.cancel(); _rollover = Timer(_day.untilNextDay, _rolloverCheck); } /// Turns Home over when the service day has moved on. /// /// Yesterday's stops are cleared **before** the refetch, not after it. A new /// day starts empty even with no signal, because the alternative is a rider /// in a dead spot at six in the morning reading yesterday's route as today's /// assignment — the exact failure this exists to stop. Anything that really /// is the new day's comes straight back from the fetch. void _rolloverCheck() { if (!mounted) return; _armRollover(); if (!_day.rollIfNeeded()) return; setState(() { _allTripStops = >[]; _ordersMap = >{}; _orderIds = []; _selectedOrders = {}; _selectedStopIds = {}; _acceptedStops = >[]; isAllSelected = false; // The fetch short-circuits on an unchanged payload, and the payload has // not changed — only the day it is read against has. Clearing the key is // what lets the new day's answer through. _lastQueuesJson = ''; }); _fetchQueues(); } /// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call /// site reads the same as it did while the rule itself is reachable from a /// test — a precedence contract that can only be exercised by building the /// whole page is a contract nobody checks. static bool _localArrivalMayShow(StopStatus serverSaid) => HomepageArrivalPrecedence.localArrivalMayShow(serverSaid); /// Applies a set of stops to the trip state. Shared by the normal fetch and /// the signed-out demo path. void _applyStops( List> allStops, Set acceptedIds, Set rejectedIds, ) { if (!mounted) return; final Map> ordersMap = {}; final List orderIds = []; for (final stop in allStops) { final id = (stop['orderid'] ?? '').toString(); if (id.isEmpty) continue; if (_hiddenOrderIds.contains(id)) continue; if (acceptedIds.contains(id) || rejectedIds.contains(id)) continue; final st = stopStatusFromRaw(stop['orderstatus']); if (st == StopStatus.active || st == StopStatus.accepted || st == StopStatus.arrived || st == StopStatus.picked || st == StopStatus.cancelled || st == StopStatus.rejected || st == StopStatus.skipped) { continue; } ordersMap[id] = stop; orderIds.add(id); } setState(() { _fetchFailed = false; _allTripStops = allStops; _acceptedIds = acceptedIds; _rejectedIds = rejectedIds; _ordersMap = ordersMap; _orderIds = orderIds; _selectedOrders = {for (final id in orderIds) id: false}; }); } Future _fetchActivePickups(int userId) async { try { List itemsV2 = []; try { itemsV2 = await _pickupProvider.getCurrentPickups(); } catch (e) { debugPrint('[HOMEPAGE] Error fetching active bookings: $e'); } // A stop the rider has already closed is not active, whatever this // endpoint still says. It lags the confirm by a poll or two, and since // these get merged into the trip (see [_tripStops]) a stale row would // reappear on Home wearing its LIVE mark seconds after he finished it. final closedIds = { ...await getCompletedOrderIds(), ...await getSkippedOrderIds(), }; // ── Through the same day filter as the queue ── // // These are merged into the trip by [_tripStops], and a row this call // carries that the queue does not is **appended** — so an unfinished // stop still reporting `active` from a shift two days ago walked past // [_todayOnly] and back onto Home through the side door, wearing a LIVE // mark. One day, one rule, however the row arrived. final activeOrders = _todayOnly( itemsV2.whereType>().where((booking) { final status = (booking['orderstatus']?.toString().toLowerCase() ?? '').trim(); if (status != 'active') return false; return !closedIds.contains((booking['orderid'] ?? '').toString()); }).toList(), ); if (mounted) { setState(() { _activePickups = activeOrders; }); } } catch (e) { debugPrint('[HOMEPAGE] Error fetching active pickup: $e'); } } /// Opens the map screen for the stop the rider is on, from its row on Home. /// /// This used to be `_navigateToActivePickup`, which — despite the name — /// navigated nowhere: it waited half a second and refetched the queue. The /// banner that called it was therefore a red strip across the bottom of the /// screen whose only visible effect on tap was a spinner. Bookings has had /// the real entry point all along; Home just never used it. Future _continueStop(Map stop) async { if (!mounted) return; debugPrint('[HOMEPAGE] Continue active stop: ${stop['orderid']}'); await pickups.MyPickups.navigateToPickupMap(context, stop); if (!mounted) return; await _fetchQueues(); } // ==================== PICKUP LOG METHODS ==================== void _startPickupPosting(Map it) async { final orderId = (it['orderid'] ?? '').toString(); if (orderId.isEmpty) return; final base = { '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': orderId, 'orderstatus': 'picked', }; _pickupBasePayload[orderId] = base; 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', (base['orderstatus'] ?? 'picked').toString(), ); } catch (_) {} await _postPickupLog(orderId); final interval = await _getLogInterval(); _pickupTimers[orderId]?.cancel(); _pickupTimers[orderId] = Timer.periodic(interval, (_) { _postPickupLog(orderId); }); } Future _postPickupLog(String orderId) async { try { Map? base = _pickupBasePayload[orderId]; if (base == null) { try { final prefs = await SharedPreferences.getInstance(); 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') ?? 'picked', }; } catch (_) {} } if (base == null) return; final coords = await _getValidCoordinates(); 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')}'; final payload = { ...base, 'logdate': logdate, 'latitude': (coords?.$1 ?? '0'), 'longitude': (coords?.$2 ?? '0'), }; await _pickupLogProvider.createPickupLog(payload); } catch (e) { debugPrint('[COLLECTIONLOG][POST] error: $e'); } } Future _getLogInterval() async { try { final prefs = await SharedPreferences.getInstance(); // Already correct — routed through the shared resolver so the 30 lives in // one place. See [kDefaultLogSeconds] for what a zero here used to cost // the rider-log loop, which read the same pref and did not guard it. final int interval = resolveLogSeconds(prefs.getInt('logseconds')); return Duration(seconds: interval); } catch (_) { return const Duration(seconds: kDefaultLogSeconds); } } // ==================== BULK ACTION ==================== String _getBulkButtonLabel() { final selectedOrderIds = _selectedOrders.entries .where((e) => e.value) .map((e) => e.key) .toList(); if (selectedOrderIds.isEmpty) return "Accept Bookings"; final statuses = selectedOrderIds .map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {})) .toSet(); if (statuses.length == 1 && statuses.first == 'ACCEPT') { return "Accept Bookings"; } if (statuses.length == 1 && statuses.first == 'ACCEPTED') { return "Move to Arrived"; } if (statuses.length == 1 && statuses.first == 'ARRIVED') { return "Move to Picked"; } if (statuses.contains('ACCEPT')) { return "Accept Bookings"; } else if (statuses.contains('ACCEPTED')) { return "Move to Arrived"; } else { return "Move to Picked"; } } /// Today's stops, grouped into the trips the hub manager actually assigned. /// // ── Why [_allTripStops] is NOT filtered through [WorkBoundary] ── // // It was, briefly, and it broke two things at once — worth writing down so // nobody re-applies it. // // `WorkBoundary` answers "which screen **works** this order?". Home answers a // different question: "what is my day?". Those are not the same list. // // • `pickupQueue` drops `WorkDomain.closed`, and this list is documented to // keep finished stops — the trip's progress ring needs them as its // denominator. Filtering them turned "3 of 8 done" into "3 of 3 done". // • On logistics the boundary is *acceptance*, so every accepted booking is // `WorkDomain.delivery`. Home still has to show them: they are the rider's // run, and the kitchen group expands to list exactly those orders. The // filter emptied the group and the dropdown stopped opening. // // The guarantee the boundary exists for is about **actions**, not about // display: Deliveries lists only what `deliveryQueue` admits, and Home's own // rungs are gated by `MilkRun.worksOnOwnScreen`. An order can be *visible* on // Home as part of the day while being *worked* on Deliveries — what it can // never be is actionable in two places. /// Built from [_allTripStops] — every stop in any state, finished ones /// included. The finished stops are hidden from the *list* rather than /// dropped from the trip (see `TripCard._sequence`), because the trip's /// figures are computed from it: strip them here and "2 of 6 stops done" /// becomes "0 of 4", and the ring empties itself every time the rider /// finishes something. List get _trips => Trip.groupIntoTrips( _tripStops, hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); /// How much work is waiting on the **other** tab. /// /// The number behind the floating pill, whose tap goes straight to /// Deliveries — so it has to be that tab's own count, or the rider is sent to /// a screen holding a different number of rows than the badge he pressed. /// /// That is exactly what it used to be: `accepted + collected` over Home's own /// view, which on a kitchen line counted a stop from the moment he accepted /// it — an hour before anything was in his hands and before Deliveries would /// list it at all. The pill said 6, the tab showed 0, and neither was wrong /// about its own question. /// /// Asked of [WorkBoundary] now, from the same day and the same sets the other /// screen fetches with, so the two cannot disagree. int get _acceptedWaitingCount => WorkBoundary.deliveryQueue( _tripStops, collectedIds: _collectedIds, acceptedIds: _acceptedIds, pivotActions: _pivotActions, // ── The set the other screen was passing and this one was not ── // // Both screens call [WorkBoundary.deliveryQueue], which was supposed to be // what stopped them disagreeing. It cannot do that when they hand it // different arguments: Deliveries passed `closedIds`, Home did not, and a // delivered stop therefore left one list and stayed in the other's count. // The rider finished a drop, watched the tab go 22 → 21, and came back to // Home still being told he had 22 in hand. // // It is worse than a stale figure here, because Home *stamps* a closed // stop `picked` (see the local-records loop in `_fetchBookings`) so its // row cannot flicker back to LIVE while the queue catches up. `picked` is // squarely inside the delivery domain, so the pill was counting the stamp // that was put there to hide it. closedIds: _closedIds, ).length; /// ── Today's work, as one run ── /// /// Every stop the rider holds today, slot boundaries collapsed. This is what /// the *counters* are derived from — the accepted pill has to mean "you are /// holding this much", which is a fact about the day and not about whichever /// tab happens to be on screen. /// /// The screen itself is per-trip: the hub assigns work in slots, dispatch /// rings the rider about "the second trip", and [_trips] is that grouping. See /// [_selectedTrip] and the [TripSelector] in the header. Trip get _todayTrip => Trip.fromStops( id: 'today', stops: _tripStops, hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); /// The trip the tab bar currently points at, or null when that slot has not /// been assigned yet. /// /// Null is a real state, not an error: the tabs deliberately show Trip 2 and /// Trip 3 before the hub has filled them, so the rider can see that the day /// has room left in it. That case renders [_buildTripEmptyState], not the /// whole-day empty state — "nothing in this slot yet" and "you have no work" /// are different sentences and only one of them is true while Trip 1 runs. Trip? get _selectedTrip { final trips = _trips; // Slot, not position — see [TripSlots.tripAt]. Slot 1 is the afternoon // whether or not there is morning work, so an empty slot is "nothing // assigned for that part of the day", not "past the end of the list". if (_selectedTripIndex < 0) return null; return trips.tripAt(_selectedTripIndex); } /// [_allTripStops] plus whatever the rider is actually on. /// /// The two lists come from different endpoints — the queue, and the /// current-pickups call behind [_fetchActivePickups] — and the queue does not /// always still carry a stop once it has gone active. That was survivable /// while the live stop was announced by its own floating banner; now that the /// row *is* the announcement, a stop missing from the trip would mean a rider /// with a job running and nothing on Home saying so. /// /// Merged by order id, live copy last so it wins on status. List> get _tripStops { if (_activePickups.isEmpty) return _allTripStops; final live = >{ for (final s in _activePickups) if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid'] ?? '').toString(): s, }; if (live.isEmpty) return _allTripStops; final merged = >[]; for (final s in _allTripStops) { final id = (s['orderid'] ?? '').toString(); merged.add(live.remove(id) ?? s); } merged.addAll(live.values); return merged; } /// Ticked stops that are still *this* trip's and still undecided. /// /// Deliberately not just `_selectedStopIds`. That set can outlive the thing /// it points at in two ways, and the floating [SelectionBar] would report /// both as work waiting to be accepted: /// /// • **A stop stops being pending.** Accepting or rejecting one on its own /// row leaves the tick behind, so the count would keep including a stop the /// rider has already decided. /// • **The rider changes tab.** Selection belongs to one trip; a tick made on /// Trip 1 must not be counted while Trip 2 is on screen — see the /// `_selectedStopIds` reset in the tab's `onSelect`. /// /// This mirrors exactly what the trip footer computes for its select-all row, /// so the bar and the row can never disagree about the number. Iterable _selectedPendingIds() => (_selectedTrip?.pendingOrderIds( acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, ) ?? const []) .where(_selectedStopIds.contains); /// How many ticks the bar is acting on. /// /// Pending-only for a parcel route, where ticking exists solely to choose /// what to accept. A service rider keeps his selection through *arrived* and /// *picked up*, so counting only undecided stops would hide the bar the /// moment he accepted the very set he is about to walk into a kitchen with. int get _selectionCount => ServiceProfile.active.handsOffAtCollection ? _selectedStopIds.where((id) => _stopById(id) != null).length : _selectedPendingIds().length; /// Accepts exactly the stops the rider ticked. /// /// The middle ground between one-at-a-time and all-at-once: on a long trip /// he can scan the list, tick the four addresses he knows are open, and take /// them in a single action instead of eight taps. /// ── Both filters here were keyed on the undecided list ── /// /// `_ordersMap.containsKey` dropped every already-accepted id before the /// action ran, and `_selectedOrders` was then rebuilt over `_orderIds` — /// also undecided-only — so an accepted id could not survive into /// [_handleBulkAction] by either route. The selection bar could accept and /// nothing else, whatever its label said. /// /// Both now resolve through [_bulkStopFor], so a ticked set that has already /// been accepted arrives intact and the action advances it to arrived, and /// then to picked. Which rung it is remains [_handleBulkAction]'s decision, /// read off the stops themselves. Future _acceptSelectedStops(List orderIds) async { final ids = orderIds.where((id) => _bulkStopFor(id) != null).toList(); if (ids.isEmpty || _acceptingTripId != null) return; setState(() { _acceptingTripId = 'selection'; _selectedOrders = {for (final id in ids) id: true}; }); try { await _handleBulkAction(); } finally { if (mounted) { setState(() { _acceptingTripId = null; _selectedStopIds = {..._selectedStopIds}..removeAll(ids); }); } } } /// Sends the crate photo for a kitchen load. Never throws at the caller. /// /// A failed upload costs the hub a picture; a failed upload that surfaced as /// an error at a counter would cost the rider his place in the queue. So it /// is logged and dropped — the load itself is already recorded, and that is /// the part the run depends on. Future _uploadLoadProof(String path, String kitchenName) async { try { final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userid') ?? 0; if (userId <= 0) return; final url = await Get.find().uploadProofImage( File(path), 'picked', userId, 0, ); debugPrint('[LOAD] $kitchenName crate photo → ${url ?? 'not stored'}'); } catch (e) { debugPrint('[LOAD] crate photo upload failed: $e'); } } /// Moves one stop up the ladder from its row on Home: `ARRIVED`, then /// `PICKED`. /// /// ── Where the two rungs actually happen ── /// /// On a parcel route both are at the customer's door and belong to Bookings. /// On a service run they are at the **kitchen**: the rider arrives, is handed /// the food, and only then does the order turn into a delivery. So the stop /// stays on Home for both, and this is what walks it through them. /// /// `PICKED` is the hand-off. It is written to the server — the parcel really /// has been collected, which is exactly what the status means — and recorded /// locally as collected, which is what moves the card to Bookings. /// Advances one stop a rung. /// /// Returns null when the rung was taken, and **the reason** when the server /// refused it — see the PICKED branch below. It used to return `void`, so a /// refused pivot left the stop on Home with nothing on screen to say why and /// the confirmation sheet reporting success over the top of it. Future _advanceStop( Map stop, String nextStatus, ) async { final orderId = (stop['orderid'] ?? '').toString(); if (orderId.isEmpty) return null; try { final dc = Get.find(); final pickupId = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0; final orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0; final pickupLocationId = int.tryParse('${stop['pickuplocationid'] ?? 0}') ?? 0; final riderLoc = await _getValidCoordinates(); final pickupLat = _parseDouble( stop['pickuplat'] ?? stop['PickupLat'] ?? 0, ).toString(); final pickupLng = _parseDouble( stop['pickuplon'] ?? stop['PickupLon'] ?? 0, ).toString(); bool ok = false; if (nextStatus == 'ARRIVED') { ok = await dc.updateArrivedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, ridersLat: riderLoc?.$1 ?? '0', ridersLng: riderLoc?.$2 ?? '0', pickupLat: pickupLat, pickupLng: pickupLng, ); } else { ok = await dc.updatePickedStatus( pickupId: pickupId, orderId: orderId, orderHeaderId: orderHeaderId, pickupLocationId: pickupLocationId, ridersLat: riderLoc?.$1 ?? '0', ridersLng: riderLoc?.$2 ?? '0', pickupLat: pickupLat, pickupLng: pickupLng, notes: '', ); } // ── A failed write moves nothing ── // // Every other status write on this screen is optimistic, and for arrival // that is right: the rider is standing at a counter, the row is his own // report of where he is, and snapping it back because a poll is late // reads as the button having done nothing. // // Pickup-complete is not that kind of write. It is the boundary between // the two halves of the app — see [WorkBoundary] — and crossing it on an // unconfirmed call is how an order ends up on Deliveries while the // backend still has it assigned for collection, which no refresh can // repair because the local record outranks the queue by design. So the // hand-over waits for the server, and a failure leaves the stop exactly // where it is, on Home, still to be picked up. if (!ok) { debugPrint('[ADVANCE] $orderId -> $nextStatus reported failure'); // Handing the reason back rather than dropping it: a rider whose // pickup the hub refused has to be told, or the only thing he can do // is slide again at a stop that will keep refusing. See // [PickupsController.lastPickupRefusal]. if (nextStatus == 'PICKED') return dc.lastPickupRefusal ?? 'refused'; } // ── The rung the SERVER produced, not the one the button is named after ── // // `picked` was hardcoded here. In compatibility mode `pickup-complete` // releases the consignment in the same call, so the server's answer is // `Out_for_Delivery` — and stamping `picked` over it put the rider's // card one rung behind his own consignment and behind the console, which // reads the same state and calls it Active. Two screens, two truths, one // stop. // // `lastPivotConsignmentStatus` is whatever the pivot actually reported. // Absent (an older backend, a response that named nothing) it falls back // to `picked`, which is the honest reading of "collected, nothing said // about release". if (nextStatus == 'ARRIVED') { stop['orderstatus'] = 'arrived'; // ── And it has to survive the refresh this sheet is about to run ── // // The line above is a field on an in-memory row. `reached` does not // persist the rung (request 15), so the `_fetchQueues()` at the foot of // `_advanceStopWithSheet` rebuilt this row from a server that still // says `Miler_Assigned` and put it straight back on ACCEPTED — about a // second after the rider slid the sheet. See [getArrivedOrderIds]. await addArrivedOrderIds([orderId]); } else { // ── The rung is PICKED, whatever the pivot released ── // // This used to take the rung from `lastPivotConsignmentStatus`, so in // compatibility mode — `MILER_COLLECTED_STATE_ENABLED` off, where // `pickup-complete` releases the consignment itself — a collection // landed on `Out_for_Delivery` and the rider's card jumped from // ACCEPTED straight to ACTIVE. He never saw PICKED, and the round he // had not started yet was already on the road. // // Which state the *server* is in is still recorded on the row, because // `deliver` is gated on it and lying about it is how a hand-over gets // refused at a door. What the rider is shown is his own ladder: // collected here, released when he presses **Start delivery**. The two // are allowed to differ — see `MyPickups._needsRelease`, which is the // other half of this and the reason the difference is safe. final produced = ConsignmentState.values .where((s) => s.name == dc.lastPivotConsignmentStatus) .firstOrNull; stop['orderstatus'] = 'picked'; if (produced != null) stop['consignmentstatus'] = produced.name; // Off the arrived rung the moment it is collected, so the local // record can never outlive the stop it describes. await removeArrivedOrderIds([orderId]); } // Written at the one moment it is true. Nothing else records these two: // the backend keeps a booking's *current* status and returns no event // feed, so without this the rider's own history of his morning is three // rungs long. See [OrderEvent]. await stampOrderEvent( orderId, nextStatus == 'ARRIVED' ? OrderEvent.arrivedAtPickup : OrderEvent.pickedUp, ); if (nextStatus == 'PICKED') { // Confirmed by the backend, so it is safe to record: this is the write // that hands the order over to Deliveries. await addCollectedOrderIds([orderId]); await addAcceptedBookings([stop]); // One copy of the day, and it is now stale — the other screen must not // be able to render this order from a cached response that predates the // hand-over. See [WorkRepository]. unawaited(WorkRepository.instance.invalidate()); if (!mounted) return null; setState(() => _collectedIds = {..._collectedIds, orderId}); } // ── When the hub did not hear it, say so ── // // Once, plainly, and without blocking anything. `reached` currently // answers 200 and writes nothing (see // [PickupsController.updateArrivedStatus]), so the rider's ARRIVED is // his own record and not a shared one — and the first person to find // that out should not be an operator wondering why a rider has been at a // kitchen for forty minutes. final notice = dc.lastArrivalNotice; if (nextStatus == 'ARRIVED' && notice != null && mounted) { AppFeedback.infoGlobal(notice); } return null; } catch (e) { debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e'); return nextStatus == 'PICKED' ? 'Could not record this pickup — check your connection and try ' 'again.' : null; } } /// Walks one stop up its next rung, behind a slide-to-confirm sheet. /// /// ── Why the whole kitchen goes together ── /// /// He arrived at a counter, not at an order. Confirming arrival for one bag /// and leaving its two siblings on `accepted` would post three different /// states for one event and leave him sliding the same sheet three times at /// the same window. So the sheet acts on every stop from that source that is /// on the same rung — which is exactly the set the batch bar would have /// acted on, reached without having to tick anything. /// /// Collection is different and stays per bag on the group page; this is the /// arrival, which is genuinely one event. Future _advanceStopWithSheet(Map stop) async { final state = stopStateOf( stop, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, collectedIds: _collectedIds, ); final rung = switch (state) { StopState.accepted => StopStatus.accepted, // Both, because this answers *which rung is the stop on* and the answer // is the same for either: he is at the counter and the next thing he does // is collect. Splitting [StopState.arrived] out of `active` was about the // word the row prints, not about what the sheet offers next. StopState.arrived || StopState.active => StopStatus.arrived, _ => null, }; if (rung == null) return; // Siblings at the same counter, on the same rung. final batch = [ for (final s in MilkRun.sameSourceAs(stop, _allTripStops)) if (stopStateOf( s, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, collectedIds: _collectedIds, ) == state) s, ]; final ids = batch.map(MilkRun.idOf).where((id) => id.isNotEmpty).toList(); if (ids.isEmpty) return; final place = rung == StopStatus.accepted ? MilkRun.navigationLabel(stop) : MilkRun.sourceNameOf(stop); final done = await StopActionSheet.show( context, stage: rung, placeName: place, count: ids.length, lines: OrderManifest.forGroup(batch), onConfirm: (outcome) async { setState(() => _busyStopIds.addAll(ids)); try { final missing = outcome.missingOrderIds.toSet(); final loaded = [ for (final s in batch) if (!missing.contains(MilkRun.idOf(s))) s, ]; // The first refusal is the answer for the whole batch: they are one // counter, one slide and one server call each, so a hub that // refused the first has refused the event. String? refusal; for (final s in loaded) { refusal ??= await _advanceStop( s, rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED', ); } if (refusal != null) return refusal; if (rung == StopStatus.arrived) { await saveOrderLabels({ for (final line in OrderManifest.forGroup(loaded)) line.orderId: line.label, }); } if (missing.isNotEmpty) { await addNotLoadedOrderIds(missing.toList()); if (mounted) { setState(() { for (final id in missing) { _hiddenOrderIds.add(id); _ordersMap.remove(id); _orderIds.remove(id); _selectedOrders.remove(id); } }); } } if (outcome.photoPath != null) { unawaited(_uploadLoadProof(outcome.photoPath!, place)); } return null; } catch (e) { debugPrint('[RUNG] $ids failed: $e'); return 'Could not update — check your connection and try again'; } finally { if (mounted) setState(() => _busyStopIds.removeAll(ids)); } }, ); if (!done || !mounted) return; AppFeedback.successGlobal( rung == StopStatus.accepted ? 'Arrived · ${ids.length} ready to collect' : '${ids.length} picked up — deliver them from ' '${ServiceProfile.active.workTabLabel}', ); await _fetchQueues(); } /// Opens turn-by-turn navigation for one stop. /// /// The destination is [MilkRun.navigationTarget]'s, not this screen's, so the /// row icon, the detail sheet and the stop map cannot disagree about where a /// given order is going. Silent when the stop carries no coordinates for its /// current leg — the row hides the control in that case, so reaching here /// without one means the data changed under a rebuild. Future _navigateToStop(Map stop) async { final target = MilkRun.navigationTarget(stop, collectedIds: _collectedIds); if (target == null) { if (mounted) { AppFeedback.info(context, 'No location on this stop yet'); } return; } await _openExternalNavigation(target.lat, target.lng); } /// Hands the coordinates to the phone's own maps app. /// /// Native intent first, web as the fallback — the same order the stop map /// screen uses, so a rider gets the same app whichever control he presses. Future _openExternalNavigation(double lat, double lng) async { try { final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d'); if (await launchUrl(native, mode: LaunchMode.externalApplication)) return; } catch (_) {} try { await launchUrl( Uri.parse( 'https://www.google.com/maps/dir/?api=1' '&destination=$lat,$lng&travelmode=driving', ), mode: LaunchMode.externalApplication, ); } catch (e) { debugPrint('[NAV] could not open maps: $e'); } } /// The rung the ticked stops are all on, and therefore what the bar offers. /// /// Mixed selections fall back to accepting: that is the only rung that is /// safe to run against a set in different states, because it is a no-op on /// anything already taken on. SelectionAction _selectionAction() { if (!ServiceProfile.active.handsOffAtCollection) { return SelectionAction.accept; } final states = { for (final id in _selectedStopIds) if (_stopById(id) != null) stopStateOf( _stopById(id)!, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, collectedIds: _collectedIds, ), }; if (states.length != 1) return SelectionAction.accept; return switch (states.first) { StopState.accepted => SelectionAction.arrive, // He is at the counter — the next thing he does is collect. Offering // *Accept* here, which is what the missing arrived case produced, sends // him back two rungs on a stop he is standing at. StopState.arrived || StopState.active => SelectionAction.pickUp, _ => SelectionAction.accept, }; } Map? _stopById(String orderId) { for (final s in _allTripStops) { if ((s['orderid'] ?? '').toString() == orderId) return s; } return null; } /// The stop a bulk selection refers to, wherever it currently lives. /// /// ── Why [_ordersMap] on its own could only ever accept ── /// /// `_ordersMap` is built from `validOrders`, which is deliberately the /// *undecided* stops only — so a stop leaves it the moment it is accepted. /// Every rung past ACCEPT looked its stop up there, got null and `continue`d, /// and the label is derived from the same map: a selection of accepted stops /// read back as `ACCEPT`, so the button said **Accept Bookings** and posted /// another accept. `Move to Arrived` and `Move to Picked` were unreachable /// from the selection bar even though [_handleBulkAction] implements both. /// /// [_allTripStops] keeps every stop in every state, which is exactly what the /// rungs past acceptance need. On a milk run the order stays on Home to be /// walked accepted → arrived → picked — see [MilkRun]. Map? _bulkStopFor(String orderId) => _ordersMap[orderId] ?? _stopById(orderId); /// Walks every ticked stop up one rung, in one gesture. /// /// This is the shape of the job at a kitchen: five parcels come off one /// counter in one handover, so the rider ticks the five and confirms once — /// not five times, twice. `PICKED` is also the hand-off, so the batch that /// finishes it is the batch that lands on the deliveries tab. /// /// ── Two steps, on purpose ── /// /// The bar's button *opens* the confirmation; [StopActionSheet] carries the /// slide that commits it. Pressing "Mark as Picked" with a crate in one hand /// must not be the thing that writes to the hub — the press is easy to make /// by accident, the slide is not, and the sheet is where there is finally /// room to say which counter and how many orders he is speaking for. Future _advanceSelection() async { final action = _selectionAction(); final ids = _selectedStopIds.toList(); if (ids.isEmpty || action == SelectionAction.accept) return; final stops = [ for (final id in ids) if (_stopById(id) != null) _stopById(id)!, ]; if (stops.isEmpty) return; final arriving = action == SelectionAction.arrive; // Where he is. Every stop in a valid selection is on the same rung; when // they are also from one counter — the normal case, and the one the rider // is standing in — the sheet can name it. A mixed pick says how many // places instead of naming the wrong one. final places = { for (final s in stops) arriving ? MilkRun.navigationLabel(s) : MilkRun.sourceNameOf(s), }..removeWhere((p) => p.trim().isEmpty); final place = places.length == 1 ? places.first : ''; final done = await StopActionSheet.show( context, stage: arriving ? StopStatus.accepted : StopStatus.arrived, placeName: place, count: ids.length, // Named, with the bag each one travels in — this is the list he checks // against the shelf in front of him. One order, one bag; see // [OrderManifest]. lines: OrderManifest.forGroup(stops), onConfirm: (outcome) async { setState(() => _busyStopIds.addAll(ids)); try { // ── A bag the counter could not supply ── // // It does not move up the ladder: reporting it as picked would put a // lunch on the round that is not in the box. It leaves the route with // its own record instead — not a skip and not a cancellation, because // nobody was visited and the customer did nothing. This is the // missing-item path the separate collect sheet used to own; it lives // here now because this is the only place a pickup is confirmed. final missing = outcome.missingOrderIds.toSet(); final loaded = [ for (final stop in stops) if (!missing.contains(MilkRun.idOf(stop))) stop, ]; String? refusal; for (final stop in loaded) { refusal ??= await _advanceStop(stop, action.status); } if (refusal != null) return refusal; // ── The pairing is fixed here, and never recomputed ── // // Joe is Bag 1 of the five that left this counter. Deriving that // later from whatever list is on screen breaks it the moment one is // delivered, so it is written down at the one moment it is true and // read back unchanged for the rest of the round. See [OrderManifest]. if (action == SelectionAction.pickUp) { await saveOrderLabels({ for (final line in OrderManifest.forGroup(loaded)) line.orderId: line.label, }); } if (missing.isNotEmpty) { await addNotLoadedOrderIds(missing.toList()); } // One shot of the whole load, uploaded against the load rather than // any one order. Fire-and-forget: a rider at a hatch must not wait on // kitchen wifi, and the pickup is recorded either way. if (outcome.photoPath != null) { unawaited(_uploadLoadProof(outcome.photoPath!, place)); } // ── Collecting is not setting off ── // // The release used to ride along with the pickup here, so the moment // the rider slid to confirm five bags at a counter the consignments // went `Out_for_Delivery` and the hub's board showed five *active* // deliveries — for food still on the shelf in front of him. Picked // and out-for-delivery are two different claims about where the load // is, and only he can make the second one. // // It moved to where the load is: the START DELIVERY bar at the foot // of the deliveries tab. See [MyPickups.startRound]. if (mounted && missing.isNotEmpty) { setState(() { for (final id in missing) { _hiddenOrderIds.add(id); _ordersMap.remove(id); _orderIds.remove(id); _selectedOrders.remove(id); } }); } return null; } catch (e) { debugPrint('[RUNG] $ids failed: $e'); return 'Could not update — check your connection and try again'; } finally { if (mounted) setState(() => _busyStopIds.removeAll(ids)); } }, ); if (!done || !mounted) return; // The selection has served its purpose — and on a pick-up it now points at // orders that have left this screen for the deliveries tab. setState(() => _selectedStopIds = {}); AppFeedback.successGlobal( action == SelectionAction.pickUp ? '${ids.length} picked up — deliver them from ' '${ServiceProfile.active.workTabLabel}' : 'Arrived · ${ids.length} ready to collect', ); await _fetchQueues(); } void _toggleStopSelection(String orderId) { // Unticking is never blocked — a rider must always be able to back out of // a selection he is halfway through. if (_selectedStopIds.contains(orderId)) { setState(() => _selectedStopIds = {..._selectedStopIds}..remove(orderId)); return; } // ── One kitchen at a time, once he is past accepting ── // // Acceptance is per order and crosses kitchens freely: he takes on all six // of the morning's orders in one press, whichever counter they come from. // // **Pickup does not.** Arriving and collecting happen at one counter, and a // selection spanning two kitchens would post "arrived" for a kitchen the // rider is nowhere near and mark bags collected that are still in another // shop's warmer. So the moment the ticked set is accepted work, it is bound // to a single source. // // Refused with the reason rather than silently swapping the selection: // dropping his existing ticks to honour the newest tap loses work he did // deliberately, and he would not see which ones went. final incoming = _stopById(orderId); if (incoming != null && _isPickupSelection && _selectedStopIds.isNotEmpty) { final anchor = _stopById(_selectedStopIds.first); if (anchor != null && !MilkRun.sameSource(anchor, incoming)) { final anchorName = MilkRun.sourceNameOf(anchor); AppFeedback.info( context, anchorName.isEmpty ? 'Collect one kitchen at a time — finish this pickup first' : 'Finish the $anchorName pickup first, then this one', ); return; } } setState(() => _selectedStopIds = {..._selectedStopIds}..add(orderId)); } /// True when the ticked set is accepted work — i.e. the next slide is a /// pickup rung rather than an accept, and the kitchen bound applies. bool get _isPickupSelection { if (!ServiceProfile.active.handsOffAtCollection) return false; for (final id in _selectedStopIds) { final stop = _stopById(id); if (stop == null) continue; final state = stopStateOf( stop, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, collectedIds: _collectedIds, ); if (state == StopState.accepted || state.isAtSource) return true; } return false; } /// Declines exactly the stops the rider ticked, after asking why once. /// /// The mirror of [_acceptSelectedStops], and it works the same way: one /// deliberate press on a set he assembled himself, rather than a control on /// every row. One reason covers the batch — a rider ticking four stops is /// declining them for one cause ("all past my area", "no time left"), and /// asking four times would make him pick something, anything, four times. Future _rejectSelectedStops(List orderIds) async { final stops = orderIds .map((id) => _ordersMap[id]) .whereType>() .toList(); if (stops.isEmpty || _acceptingTripId != null) return; final reason = await _askRejectReason(stops.length); if (reason == null || !mounted) return; setState(() => _acceptingTripId = 'selection'); try { for (final stop in stops) { await _commitRejection( stop, reason: reason, message: '', notify: false, ); } if (!mounted) return; setState(() { _selectedStopIds = {..._selectedStopIds}..removeAll(orderIds); }); _showStopSnack( stops.length == 1 ? 'Stop rejected — your office has been told' : '${stops.length} stops rejected — your office has been told', ColorConstants.secondaryText, ); await _fetchQueues(); } finally { if (mounted) setState(() => _acceptingTripId = null); } } /// Reject reasons, phrased as things that actually happen on a route. /// /// A reason is required because the hub needs to know whether to re-route the /// stop to another miler or call the customer — "rejected" with no cause is an /// operational dead end. Future _askRejectReason(int count) async { const reasons = [ 'Too far from my route', 'Shop / customer closed today', 'Parcel too large for my box', 'Not enough time left in my shift', 'Address looks wrong', ]; // Through the kit; content unchanged. (This one also applied a bare // `SafeArea` INSIDE an opaque sheet — the scaffold's single inset rule // replaces it.) return showMilerSheet( context, builder: (ctx) => MilerSheetScaffold( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MilerSheetHeader( title: count == 1 ? 'Why are you rejecting this stop?' : 'Why are you rejecting these $count stops?', subtitle: count == 1 ? 'Your office needs a reason to re-route it to someone else.' : 'Your office needs a reason to re-route them to someone else.', ), SizedBox(height: 14.h), for (final r in reasons) ...[ MilerButton( label: r, variant: MilerButtonVariant.outlined, color: ColorConstants.slateText, height: ButtonSizes.secondary, onPressed: () => Navigator.pop(ctx, r), ), SizedBox(height: 8.h), ], SizedBox(height: 4.h), MilerButton( label: count == 1 ? 'Keep this stop' : 'Keep them', variant: MilerButtonVariant.tonal, color: ColorConstants.acceptGreen, onPressed: () => Navigator.pop(ctx, null), ), ], ), ), ); } /// Returns a rejected stop to undecided. /// /// Not an accept: it goes back to the top of the screen as a tickable stop, /// and it is taken the same way as everything else — ticked, then accepted /// from the bar. A row control that quietly committed work would be the /// per-stop Accept button coming back through the side door. Future _unrejectStop(Map stop) async { final orderId = (stop['orderid'] ?? '').toString(); if (orderId.isEmpty || _busyStopIds.contains(orderId)) return; setState(() => _busyStopIds.add(orderId)); try { await removeRejectedOrderIds([orderId]); if (!mounted) return; setState(() { _rejectedIds = {..._rejectedIds}..remove(orderId); _hiddenOrderIds.remove(orderId); stop['orderstatus'] = ''; }); _showStopSnack( 'Back in the list — tick it to take it', ColorConstants.secondaryText, ); await _fetchQueues(); } finally { if (mounted) setState(() => _busyStopIds.remove(orderId)); } } /// Takes back the acceptance the rider just made, from the UNDO on its /// snackbar. /// /// A rejection on the wire, not a silent local rollback: the accept already /// went to the hub, so those stops are assigned to this miler until something /// says otherwise. No reason sheet — he is correcting a tap he made seconds /// ago, and interrupting an undo with a questionnaire is how you make people /// stop using undo. The reason is stated for him so the hub can still tell /// this apart from a considered refusal. /// /// One snackbar and one refetch for the whole batch, which is why /// [_commitRejection] takes [notify]. Future _undoAcceptedBatch(List> stops) async { final taken = stops .where((s) => _acceptedIds.contains((s['orderid'] ?? '').toString())) .toList(); if (taken.isEmpty) return; for (final stop in taken) { await _commitRejection( stop, reason: 'Accepted by mistake — undone by the miler', message: '', notify: false, ); } if (!mounted) return; // Undo is pressed from the Bookings tab, because that is where the accept // just sent him. Put him back where the stop went, or he is left looking at // a list that silently lost a row. BottomPage.goToTab(0); _showStopSnack( taken.length == 1 ? 'Acceptance undone — the stop is back with your office' : '${taken.length} acceptances undone — back with your office', ColorConstants.secondaryText, ); await _fetchQueues(); } /// Tell the hub, drop the local acceptance, record the rejection, say so. /// /// [notify] off suppresses the per-stop snackbar and refetch so a batch can /// do both once at the end. Future _commitRejection( Map stop, { required String reason, required String message, bool notify = true, }) async { final orderId = (stop['orderid'] ?? '').toString(); if (orderId.isEmpty || _busyStopIds.contains(orderId)) return; setState(() => _busyStopIds.add(orderId)); try { final dc = Get.isRegistered() ? Get.find() : Get.put(PickupsController(), permanent: true); bool success = false; try { success = await dc.updateRejectedStatus( pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0, orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0, notes: reason, ); } catch (e) { debugPrint('[STOP_REJECT] $orderId failed: $e'); } await addRejectedOrderIds([orderId]); if (success) dc.triggerRefresh(); // ── A declined stop is filed, not deleted ── // // It used to stay on Home, struck through, with an UNDO beside it, and // it was recorded nowhere else — so the only record of a decision the // hub will ask about lived on the one screen the rider clears as he // works. Now it leaves Home immediately (see `TripCard._showsOnHome`) // and lands on Activity with the rest of the day, stamped `rejected` so // the row can say **Declined** rather than pretending it was completed. await addCompletedBookings([stop], terminalStatus: 'rejected'); if (!mounted) return; await removeAcceptedBookings([orderId]); if (!mounted) return; setState(() { _rejectedIds = {..._rejectedIds, orderId}; _acceptedIds = {..._acceptedIds}..remove(orderId); _selectedStopIds = {..._selectedStopIds}..remove(orderId); _hiddenOrderIds.add(orderId); _ordersMap.remove(orderId); _orderIds.remove(orderId); _selectedOrders.remove(orderId); }); if (notify) { _showStopSnack(message, ColorConstants.secondaryText); await _fetchQueues(); } } finally { if (mounted) setState(() => _busyStopIds.remove(orderId)); } } /// One-line confirmation, optionally carrying the action that reverses what /// it is confirming. /// /// The action is a plain text label rather than anything filled: Material is /// explicit that a snackbar action must not out-shout the message it sits /// beside, and this one is a safety net, not the main path. /// ── A confirmation, not a thing to dismiss ── /// /// This was a Material `SnackBar` at 4 seconds — 8 on an accept, to leave /// room for an UNDO. On the rider's phone that reads as a panel that has /// *arrived and stayed*: it covers the foot of the route, it sits over the /// floating nav bar's neighbourhood, and the only obvious way to be rid of it /// is to swipe it down. Confirming six accepted stops is not worth eight /// seconds of the screen the rider is trying to keep working on. /// /// `Flushbar` (another_flushbar, pub.dev) at **2 seconds**: it announces, it /// leaves, and nothing has to be dismissed. It also animates in and out on /// its own clock rather than the Scaffold's, so it is unaffected by the tab /// switch an accept triggers. /// /// [duration] is kept as a parameter but defaults to the 2s the rider asked /// for; anything that genuinely needs longer has to say so at the call site. void _showStopSnack( String message, Color color, { String? actionLabel, VoidCallback? onAction, Duration duration = const Duration(seconds: 2), }) { if (!mounted) return; // One at a time. Two accepts in quick succession would otherwise stack. _flushbar?.dismiss(); final bar = Flushbar( messageText: Text( message, style: TextStyle( fontFamily: FontConstants.fontFamily, fontSize: 14.sp, fontWeight: FontWeight.w700, color: Colors.white, ), ), icon: Icon( LucideIcons.circleCheck, size: 20.sp, color: Colors.white.withValues(alpha: 0.95), ), backgroundColor: color, duration: duration, flushbarPosition: FlushbarPosition.BOTTOM, flushbarStyle: FlushbarStyle.FLOATING, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), // Clear of the floating nav bar — the bar is a sibling layer painted over // this Scaffold, so a toast measured from the Scaffold's own bottom lands // underneath it. margin: EdgeInsets.fromLTRB( 16.w, 0, 16.w, BottomPage.bottomInset(context) + 8.h, ), padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), animationDuration: const Duration(milliseconds: 260), forwardAnimationCurve: Curves.easeOutCubic, reverseAnimationCurve: Curves.easeInCubic, // Two seconds is not long enough to reach for an action, so a toast that // carries one is a toast making a promise it cannot keep. Anything // reversible is reversed from the tab the work landed on. mainButton: (actionLabel == null || onAction == null) ? null : TextButton( onPressed: () { _flushbar?.dismiss(); onAction(); }, child: Text( actionLabel, style: TextStyle( fontFamily: FontConstants.fontFamily, fontWeight: FontWeight.w800, color: Colors.white, ), ), ), ); _flushbar = bar; bar.show(context); } /// The toast currently on screen, so a second one replaces it rather than /// stacking behind it. Flushbar? _flushbar; /// The stops the last accept actually took, held for the UNDO on its /// snackbar. Cleared implicitly — the next accept overwrites it, and undoing /// checks each stop is still accepted before touching it. List> _lastAcceptedBatch = const []; /// Walks the ticked stops up one rung: ACCEPT → ACCEPTED → ARRIVED → PICKED. /// /// ── Where the rider ends up is the line's decision, not the caller's ── /// /// This took a `navigateToBookings` flag, defaulted to `true`, and the two /// branches it selected disagreed about the thing [ServiceProfile] already /// answers. The tick-based path passed `false` and got the profile-aware /// branch; the bulk action button passed nothing, got the default, and hit an /// unconditional `Get.offAll(BottomPage(initialIndex: 1))`. /// /// On a **milk run** that is the wrong move and it broke the whole ladder. /// The rider accepts at a kitchen, and the order has to stay on Home to be /// walked to arrived and then picked — see [MilkRun]. Throwing him at the /// work tab on accept sent him to a tab that was correctly empty, made the /// accept look like it had filed his order somewhere else, and left the two /// rungs that follow it unreachable from where he was standing. /// /// So the flag is gone. `handsOffAtCollection` is the one rule and it is read /// in one place: the line that hands off at *acceptance* (logistics) moves /// him, the line that hands off at *collection* (milk man) keeps him on Home. Future _handleBulkAction() async { final selectedOrderIds = _selectedOrders.entries .where((e) => e.value) .map((e) => e.key) .toList(); if (selectedOrderIds.isEmpty) return; final statuses = selectedOrderIds .map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {})) .toSet(); String targetStatus; if (statuses.length == 1 && statuses.first == 'ACCEPT') { targetStatus = 'ACCEPTED'; } else if (statuses.length == 1 && statuses.first == 'ACCEPTED') { targetStatus = 'ARRIVED'; } else if (statuses.length == 1 && statuses.first == 'ARRIVED') { targetStatus = 'PICKED'; } else { targetStatus = 'ACCEPTED'; } if (targetStatus == 'ARRIVED') { final proximityResults = await _checkProximityForOrders(selectedOrderIds) .timeout( const Duration(seconds: 3), onTimeout: () => Map.fromEntries( selectedOrderIds.map((id) => MapEntry(id, true)), ), ); final farOrders = proximityResults.entries .where((e) => !e.value) .map((e) => e.key) .toList(); if (farOrders.isNotEmpty) { if (mounted) { await _showProximityWarning( context, specificMessage: farOrders.length == selectedOrderIds.length ? 'You must be within 500 meters of the pickup location(s) to mark bookings as arrived.' : 'Some selected bookings are too far from their pickup locations.', ); } return; } } XFile? bulkPhoto; if (targetStatus == 'PICKED') { try { final ImagePicker picker = ImagePicker(); bulkPhoto = await picker.pickImage( source: ImageSource.camera, imageQuality: 50, ); if (bulkPhoto == null) return; } catch (e) { debugPrint('[BULK] Camera error: $e'); return; } } showAppDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withValues(alpha: 0.3), builder: (ctx) => Center( child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), SizedBox(height: 14.h), Text( 'Updating ${selectedOrderIds.length} bookings...', style: TextStyle( color: _textSecondary, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ), ); bool bulkSucceeded = false; int successCount = 0; try { final dc = Get.isRegistered() ? Get.find() : Get.put(PickupsController(), permanent: true); // The rider is physically at one place for the whole batch, so resolve // the current location once and reuse it for every booking's update. final bulkRiderLoc = await _getValidCoordinates(); final bulkRidersLat = bulkRiderLoc?.$1 ?? '0'; final bulkRidersLng = bulkRiderLoc?.$2 ?? '0'; // A bulk pick captures a single proof photo; upload it once and attach the // resulting URL to each picked booking below. String? bulkProofImageUrl; if (bulkPhoto != null) { try { final prefs = await SharedPreferences.getInstance(); final userId = int.tryParse(prefs.getString('userid') ?? '0') ?? 0; if (userId > 0) { bulkProofImageUrl = await dc.uploadProofImage( File(bulkPhoto.path), 'picked', userId, 0, ); } } catch (e) { debugPrint('[BULK] Upload error: $e'); } } final List> acceptedBookings = []; /// Orders this batch confirmed as collected, mirrored into [_collectedIds] /// once the loop is done so the cards re-read their leg immediately rather /// than at the next poll. final Set bulkCollected = {}; for (final orderId in selectedOrderIds) { // Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every // rung after the first was silently skipped here. See [_bulkStopFor]. final Booking = _bulkStopFor(orderId); if (Booking == null) continue; final pickupId = int.tryParse('${Booking['pickupid'] ?? 0}') ?? 0; final orderHeaderId = int.tryParse('${Booking['orderheaderid'] ?? 0}') ?? 0; final pickupLocationId = int.tryParse('${Booking['pickuplocationid'] ?? 0}') ?? 0; final pickupLat = _parseDouble( Booking['pickuplat'] ?? Booking['PickupLat'] ?? 0, ).toString(); final pickupLng = _parseDouble( Booking['pickuplon'] ?? Booking['PickupLon'] ?? 0, ).toString(); bool success = false; try { if (targetStatus == 'ACCEPTED') { debugPrint('[BULK] Accepting Booking $orderId'); success = await dc.updateAcceptedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, ridersLat: bulkRidersLat, ridersLng: bulkRidersLng, ); // ── An accept the backend refused is not an accept ── // // This persisted EVERY selected booking regardless of the result, // on the reasoning that a flaky network should not cost the rider // his selection. What it actually produced was a booking the app // believed was accepted and the hub had never heard of — and every // rung after it inherits that lie: the row advances to arrived and // picked, `pickup-complete` is posted against a booking still // sitting in `Miler_Assigned`, no consignment is ever created, and // the rider finds out at a customer's door when Delivered is // refused because there is nothing to deliver. // // Accept is the one rung with a cheap retry: the stop is still on // Home, still selectable, and pressing again costs a tap. So it // records what the backend confirmed and says what it did not — // the same rule `_advanceStop` already applies to PICKED. if (success) { _hiddenOrderIds.add(orderId); acceptedBookings.add(Booking); } else { debugPrint( '[BULK] accept REFUSED for $orderId — leaving it on Home', ); } } else if (targetStatus == 'ARRIVED') { debugPrint('[BULK] Arriving Booking $orderId'); success = await dc.updateArrivedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, ridersLat: bulkRidersLat, ridersLng: bulkRidersLng, pickupLat: pickupLat, pickupLng: pickupLng, ); // ── The bulk path recorded nothing at all ── // // Accept hides the row, PICKED writes two stores; ARRIVED wrote // neither, and this method finishes with `_fetchQueues()`. So a // successful bulk arrival was a network call, a success count and // a row that came straight back on ACCEPTED — which is what "mark // as arrived does nothing" looked like from the outside. if (success) { Booking['orderstatus'] = 'arrived'; await addArrivedOrderIds([orderId]); } } else if (targetStatus == 'PICKED') { debugPrint('[BULK] Picking Booking $orderId'); success = await dc.updatePickedStatus( pickupId: pickupId, orderId: orderId, orderHeaderId: orderHeaderId, pickupLocationId: pickupLocationId, ridersLat: bulkRidersLat, ridersLng: bulkRidersLng, pickupLat: pickupLat, pickupLng: pickupLng, proofImage: bulkProofImageUrl, ); if (success) { _startPickupPosting(Booking); _hiddenOrderIds.add(orderId); // ── The write that says the load is in his hands ── // // `_advanceStop` records this rung in the collected store and // this branch did not, so a bag picked from the **selection bar** // was never marked as carried. That one omission is what put // **Start Pickup** on a delivery: [MilkRun.workingKind] reads // exactly this set to decide which leg a stop is on, and the // booking adapter stamps `type: pickup` on every row, so with an // empty set a bag already in the box still described itself as a // collection. The same answer feeds `toDrop` on the queue's // distance, so the card also measured to the kitchen he had just // left instead of to the customer's door — which is the "km and // time are wrong" half of it. // // Recorded only after the backend confirmed, same rule as accept. await addCollectedOrderIds([orderId]); bulkCollected.add(orderId); // One copy of the day, and it is now stale — Deliveries must not // render this order from a response that predates the hand-over. unawaited(WorkRepository.instance.invalidate()); // ── Picked is a hand-over on one line and a finish on the other ── // // This branch closed the booking outright. That is right for // **logistics**, where what he collects goes to the hub and the // booking's own story ends at the counter. // // On a **milk run** it is wrong and it loses the order: collection // is the *middle* of the day — every bag is followed by a round of // drops he makes himself — so filing it as completed sent it to // Activity instead of to Deliveries, and the delivery leg had // nothing to work. See [ServiceProfile.handsOffAtCollection]. if (ServiceProfile.active.handsOffAtCollection) { await addAcceptedBookings([Booking]); } else { await removeAcceptedBookings([orderId]); await addCompletedBookings([Booking]); } // Off the arrived rung — it has been collected. await removeArrivedOrderIds([orderId]); } } } catch (e) { debugPrint('[BULK] Error updating Booking $orderId: $e'); success = false; } if (success) { successCount++; } else { debugPrint('[BULK] Failed to update Booking $orderId'); } } // The leg every card reads is derived from this set, so it has to land // before the next build — not at the next poll three seconds later. if (bulkCollected.isNotEmpty && mounted) { setState(() => _collectedIds = {..._collectedIds, ...bulkCollected}); } // Persist accepted bookings so the Bookings tab can show them and Home // keeps them filtered out. if (acceptedBookings.isNotEmpty) { await addAcceptedBookings(acceptedBookings); await stampOrderEvents( acceptedBookings.map((b) => (b['orderid'] ?? '').toString()), OrderEvent.accepted, ); } _lastAcceptedBatch = List>.from(acceptedBookings); setState(() { for (final b in acceptedBookings) { final id = (b['orderid'] ?? '').toString(); _ordersMap.remove(id); _orderIds.remove(id); } _selectedOrders.clear(); isAllSelected = false; }); bulkSucceeded = acceptedBookings.isNotEmpty || successCount > 0; await _fetchQueues(); // ── A partial accept has to say so ── // // The accept path used to skip this message entirely, on the grounds that // it navigates away. It now records only what the backend confirmed, so // silence would leave the rider looking at stops that did not move with // nothing to explain why. if (mounted && targetStatus == 'ACCEPTED' && acceptedBookings.length != selectedOrderIds.length) { final missed = selectedOrderIds.length - acceptedBookings.length; AppFeedback.error( context, acceptedBookings.isEmpty ? 'Could not accept — your office did not confirm it. Try again.' : '$missed of ${selectedOrderIds.length} could not be accepted — ' 'they are still on Home.', ); } // For accepts we navigate to the Bookings tab below, so skip the inline // snackbar in that case (it would be tied to this soon-to-be-replaced page). if (mounted && targetStatus != 'ACCEPTED') { final total = selectedOrderIds.length; final allOk = successCount == total; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( allOk ? 'Updated $successCount ${total == 1 ? 'booking' : 'bookings'} successfully' : 'Updated $successCount of $total bookings — the rest could not be updated', style: const TextStyle( fontFamily: FontConstants.fontFamily, color: Colors.white, ), ), backgroundColor: allOk ? _primaryRed : ColorConstants.warning, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), ), ); } } catch (e) { debugPrint('[BULK] Error: $e'); } finally { if (mounted && Navigator.of(context).canPop()) { Navigator.of(context).pop(); } } // ── Accepting takes the rider to the work ── // // This used to stop at a snackbar reading "work it from the Bookings tab", // leaving him on Home watching the stop he had just taken disappear off the // screen. From where he was standing the accept had deleted his booking: // the row was gone from Home, nothing else on screen had changed, and the // tab that did have it was one he had to know to go and open — where, for // up to three seconds, the poll had not landed yet either, so it *was* // genuinely absent when he arrived. // // So the accept now does what it says: it moves him, and it takes the data // with him. [PickupsController.triggerRefresh] makes Bookings re-fetch on // the spot rather than at the next tick of its poller, and the switch is an // IndexedStack index change — the same ScaffoldMessenger, so the UNDO // snackbar below travels with him and is still there to be pressed. if (bulkSucceeded && targetStatus == 'ACCEPTED') { final taken = _lastAcceptedBatch; final n = taken.length; try { (Get.isRegistered() ? Get.find() : Get.put(PickupsController(), permanent: true)) .triggerRefresh(); } catch (e) { debugPrint('[BULK] Could not poke Bookings to refresh: $e'); } // ── Only the line that hands off at accept gets moved ── // // On **logistics** accepting IS the hand-off: the stop leaves Home for // the work tab, so following it there is the whole point — see the note // above. // // On a **milk run** it is not. The order stays on Home to be walked up // its ladder — accepted, arrived, picked — and only reaches the // deliveries tab once it is in the rider's hands. Jumping him there on // accept sent him to a tab that was correctly empty and made the accept // look like it had filed his order somewhere else. He then had to find // his way back to Home to do the actual collecting. // // So he stays where the work is. The snackbar below still confirms what // he took, and the accepted rows are right there under it. if (!ServiceProfile.active.handsOffAtCollection) { BottomPage.goToTab(1); } // ── The undo window ── // // Accepting is now one deliberate press on a set the rider assembled by // ticking, so a mis-tap is rarer than it was with a button on every row — // but it is also *bigger*, because one press can take six stops. Eight // seconds to take the whole batch back, the same trade Gmail's delete // and Material's snackbar guidance make. // Two seconds, and no UNDO on it. An eight-second panel held the foot of // the screen open purely so an action nobody could reach in time had // somewhere to live; the acceptance itself is reversible from the tab the // work lands on, which is where the rider is by the time he changes his // mind. _showStopSnack( n == 1 ? 'Stop accepted' : '$n stops accepted', ColorConstants.acceptGreen, ); return; } } /// The whole route in one object: load, distance, time windows, shift. /// /// Recomputed each build \u2014 it's a cheap pass over the pending stops, and the /// shift countdown has to stay honest as the day burns down. RouteBrief get _routeBrief => RouteBrief.from( orderIds: _orderIds, ordersMap: _ordersMap, shiftStart: _shiftStart, shiftEnd: _shiftEnd, extraStops: _acceptedStops, riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); /// A trip with nothing in it, for a slot the hub has not filled. /// /// Every figure the brief draws is derived from the stops, so an empty list /// gives `0 stops left`, a progress track at zero and an em dash under /// duration, distance and payment on its own — there is no "empty" variant of /// the brief to keep in step with the real one, because this *is* the real /// one with nothing in it. Trip _unassignedTrip() => Trip.fromStops( id: 'unassigned-$_selectedTripIndex', stops: const [], hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); /// One configured [TripCard] for the selected trip. /// /// Home does not render the card as a single widget any more: its summary, its /// stop list and its action bar go into the sliver list separately so the /// Trip 1 / 2 / 3 bar can be *pinned* between the summary and the stops. This /// holds the configuration \u2014 every callback and every id set \u2014 in one place, so /// the three sections cannot drift apart. See the note above the section /// getters in `trip_card.dart`. TripCard _tripCardFor(Trip trip) => TripCard( key: ValueKey('trip_${trip.id}'), trip: trip, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, // Shift window, pace and cash — the summary strip shows the shift and // the trip's own slot as two labelled rows, which is the whole reason // the page has to hand this down. brief: _routeBrief, busyStopIds: _busyStopIds, collectedIds: _collectedIds, // How far the next pickup is. The same fix the detail sheet and the map // already use — a live geocoded position, falling back to the last one // stored — so the distance on the route header and the distance inside the // stop cannot disagree. riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, // ── The card opens a preview, and the preview hands off ── // // `Navigate to pickup` used to go straight out to Google Maps. That is one // tap too few: handing off means the rider *leaves Miler*, and he was // making that commitment on the strength of a kitchen's name and a // straight-line distance. The preview draws the leg from where he is // standing to the counter and lists what he will collect there; its own // primary button is the real hand-off. Built here rather than inside // [TripCard] because that class's three build methods are deliberately // context-free — Home calls them directly as slivers. onPreviewGroup: (g) => PickupPreviewSheet.show( context, placeName: g.name, lines: g.manifest, meters: g.meters, ride: g.rideTime, riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, onNavigate: g.stops.isEmpty || MilkRun.navigationTarget(g.stops.first) == null ? null : () { Navigator.of(context).pop(); _navigateToStop(g.stops.first); }, onCall: g.stops.isEmpty || stopPhoneOf(g.stops.first).isEmpty ? null : () { Navigator.of(context).pop(); _callStop(g.stops.first); }, ), // Only a tenant that collects from a source gets the control; on a parcel // route there is nothing to collect from and the header never renders. selectedIds: _selectedStopIds, onToggleSelect: _toggleStopSelection, onSelectAll: (ids) => setState(() => _selectedStopIds = ids.toSet()), onAcceptSelected: _acceptSelectedStops, // The way back for a stop he declined. It does not accept anything by // itself — see the note at the row. (Resuming a *skipped* stop is // Activity's control now: a skip is a record of what happened at a // door, not an offer waiting on a decision.) onUnrejectStop: _unrejectStop, // No per-stop or whole-trip accept any more: ticking chooses, the // floating bar commits. See the decision-row note in `trip_card.dart`. // Map + full address + everything the card truncates. onStopTap: (stop) => StopDetailSheet.show( context, stop: stop, stopNumber: trip.stops.indexOf(stop) + 1, // Home is the one caller that holds the whole route, so it is the one // that can say `Stop 3 of 8`. stopCount: trip.stops.length, riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ), // ── An order tapped inside a kitchen is about its own door ── // // Same sheet, different subject. The counter is shared by every order in // the group and already has a surface of its own — the preview behind the // card's third utility, which draws the whole leg to it with the manifest // waiting there. So the order's sheet points at the customer instead of // repeating the kitchen five times. See [StopDetailSheet.ofOrder]. onOrderTap: (stop) => StopDetailSheet.show( context, stop: stop, stopNumber: trip.stops.indexOf(stop) + 1, stopCount: trip.stops.length, riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ofOrder: true, ), onCallStop: (stop) => _callStop(stop), // Setting off, straight from the row. Where it points follows the stop's // stage: the kitchen while it is still to be collected, the customer once // it is in his hands. onNavigateStop: _navigateToStop, // The single-stop ladder: a slide sheet per card, alongside the floating // bar's batch version. onAdvanceStop: _advanceStopWithSheet, // Back into the stop he is already on. This is what the red "Active // Pickup" strip at the foot of the page used to be — except that one // only refetched the queue and never actually opened anything. onContinueStop: _continueStop, // The whole route on one map. Reads the trip's own ordering into the // step numbers so the pins are labelled the way the card lists them, // rather than however the map screen would number them itself. onViewRoute: trip.stops.isEmpty ? null : () => openScreen( context, pickups.MultiCustomerMapScreen( pickup: trip.stops, preservedStepNumbers: { for (var i = 0; i < trip.stops.length; i++) (trip.stops[i]['orderid'] ?? '').toString(): i + 1, }, ), swipeToGoBack: false, ), ); /// \u2500\u2500 The app bar \u2014 pinned, never scrolls away \u2500\u2500 /// /// This used to be a `SliverToBoxAdapter` at the top of the scroll view, so /// the rider's identity *and the ON/OFF duty toggle* slid off the screen as /// soon as he looked at his second stop. The toggle is the control that /// governs everything else on the page \u2014 going off duty stops work being /// assigned \u2014 so needing to scroll back up to reach it was the wrong trade. /// It is now a sibling above the scroll view, which pins it outright. /// /// Only the identity row is pinned. The shift window that used to share this /// white block scrolls with the content now (see [_buildShiftRow]): it is /// read-once reference information, and holding it up here would spend ~44 /// permanent pixels \u2014 on top of the status bar, this bar, the trip tabs and /// the bottom nav \u2014 to keep a static time range on screen. /// ── The header's data, assembled ── /// /// The header itself is [HomeHeaderDelegate] — a pinned sliver inside the /// page's own `CustomScrollView`, not a fixed block above it. This composes /// the strings it draws, from the state the page already holds. No new /// source, no extra call: `_trips` is the same getter `build` reads and the /// duty flags are the same fields the old switch read. SliverPersistentHeader _buildHeaderSliver(BuildContext context) { final String firstName = _userName.isNotEmpty ? _userName.split(' ').first : 'Rider'; final List headerTrips = _trips; final Trip? current = headerTrips.tripAt(_selectedTripIndex); // The one figure the whole shift is steered by, computed once and shown // once. The run card below used to print the same arithmetic again. final int total = current?.stopCount ?? 0; final int done = current ?.states(acceptedIds: _acceptedIds, rejectedIds: _rejectedIds) .where((st) => st.isResolved) .length ?? 0; final int left = (total - done).clamp(0, total); final String subtitle = total == 0 ? (isOnline ? 'Waiting for work' : 'You are off duty') : left == 0 ? 'Run complete' : left == 1 ? '1 stop remaining' : '$left stops remaining'; // `Trip 1 · 17 left · 33/50` — the collapsed line, from the same three // figures the expanded tabs draw, so the two states cannot drift. final String collapsed = total == 0 ? '${Trip.tabLabel(_selectedTripIndex)} · $subtitle' : '${Trip.tabLabel(_selectedTripIndex)} · $left left · $done/$total'; return SliverPersistentHeader( pinned: true, delegate: HomeHeaderDelegate( topInset: MediaQuery.paddingOf(context).top, expandedContent: HomeHeaderDelegate.expandedContentFor(context), greeting: '${_partOfDay()}, $firstName', subtitle: subtitle, onDuty: isOnline, // Unchanged rules, new presentation: duty is locked mid-pickup because // going off would strand a customer, and refused while a write is in // flight. dutyLocked: _hasActivePickup(), dutyBusy: _isToggling, // The confirmation sheet still owns the decision — see // [_confirmOnlineOffline]. Nothing here changes duty by itself. onToggleDuty: _confirmOnlineOffline, collapsedSummary: collapsed, ), ); } /// `Good morning` / `Good afternoon` / `Good evening`. /// /// The greeting was `Hi, Rajan` — a word with no information in it. The time /// of day costs the same line and tells a rider on a split shift which half /// of it he is in. String _partOfDay() { final hour = DateTime.now().hour; if (hour < 12) return 'Good morning'; if (hour < 17) return 'Good afternoon'; return 'Good evening'; } /// ── Duty control ── /// /// The presentation lives in [DutyPill] now, in the header file. What was /// here was `AnimatedToggleSwitch.dual` — a 34pt two-position track carrying /// the words *On duty* and *Off duty* — which is a settings control, and was /// the second loudest object in a header whose subject is the work. See the /// pill for the reasoning; the package it used is no longer imported by this /// screen. /// /// **None of the duty logic moved.** [_confirmOnlineOffline] still owns the /// decision, the confirmation sheet still runs, `_isToggling` still gates a /// second press and `_hasActivePickup()` still locks it mid-stop. /// Shown on a trip tab the hub has not filled yet. /// /// The whole thing lives in [TripEmptyState] — including the reasoning for /// why it is no longer a bordered card. Keyed on the tab so switching from /// one unassigned slot to another restarts the entrance rather than silently /// swapping the heading. Widget _buildTripEmptyState(int index) { return TripEmptyState( key: ValueKey('trip-empty-$index'), tripLabel: Trip.tabLabel(index), offline: !isOnline, onGoOnDuty: _confirmOnlineOffline, ); } Widget _buildEmptyState() { // Offline riders don't receive bookings, so the empty state must explain // *why* it's empty and offer the one action that fixes it — going online. final bool offline = !isOnline; // ── Online, nothing assigned: the art illustrates, the type speaks ── // // The artwork ships with its own headline baked into the pixels — "No // Bookings **Available**" with a red accent word, and a marketing line // ending in an exclamation mark. Baked type cannot be Poppins, cannot // scale with the rider's system text size, and reads as a poster pasted // into the app rather than the app talking. So the image is cropped to // its illustration — the bottom band where the words live is cut — and // the message is set below it in the design system's own voice, exactly // as every other empty state on the app does. if (!offline) { return Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 28.w), child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── The artwork carries its own white, so give it an edge ── // // Seen on a device: this asset has a white background baked in, // and Home's ground is the canvas — so a hard white rectangle // sat on grey with visible corners, reading as a failed image // rather than an illustration. // // Rounding it turns the asset's own ground into a deliberate // layer-1 surface. Nothing is masked or recoloured; the picture // simply stops having square corners on a page that has none. SizedBox( width: 450.w, child: AspectRatio( // The illustration occupies the top ~75% of the 3:2 asset; // everything below that line is the baked copy being // removed. Top-aligned cover crop keeps scooter, rider and // pin whole. aspectRatio: 1536 / 770, child: ClipRRect( borderRadius: BorderRadius.circular( DesignConstants.radiusXl, ), child: Image.asset( 'assets/images/no_bookings_available.png', fit: BoxFit.cover, alignment: Alignment.topCenter, ), ), ), ), SizedBox(height: 18.h), Text( 'No bookings yet', textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 6.h), Text( 'Stay on duty new bookings appear here the moment ' 'your office assigns you.', textAlign: TextAlign.center, style: TextStyle( fontSize: 13.5.sp, height: 1.45, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ); } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Offline Lottie in its native colors. SizedBox( width: 210.w, height: 180.w, child: Lottie.asset( 'assets/lotties/no_internet.json', fit: BoxFit.contain, repeat: true, ), ), SizedBox(height: 12.h), Text( "You're Offline", textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), ), SizedBox(height: 8.h), Text( "Go online to start receiving bookings", textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), SizedBox(height: 18.h), ElevatedButton.icon( onPressed: _hasActivePickup() ? null : _confirmOnlineOffline, icon: Icon(LucideIcons.zap, size: 18.sp), label: Text( "Go Online", style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), style: ElevatedButton.styleFrom( backgroundColor: _primaryRed, foregroundColor: Colors.white, elevation: 0, // Mid-size: content-hugging but with a comfortable min width, // so it's neither full-width nor a tiny chip. The height is // the unscaled [ButtonSizes.compact] rather than `44.h` — // ScreenUtil would shrink a scaled 44 below the tap-target // floor on a short phone, which is where it matters most. minimumSize: Size(150.w, ButtonSizes.compact), tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 11.h), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ButtonSizes.radius), ), ), ), ], ), ); } // ── `_buildBookingCard` is gone ── // // A ~600-line alternate Home, unreachable: no call site anywhere in the app. // It drew a stop as a bordered, shadowed, rounded card with its own status // pill, its own accept/reject pair and its own address treatment — a second // and contradictory visual system for the same object the route node now // draws, sitting in the production file waiting for somebody to wire it back // up because it looked finished. // // Nothing was salvaged from it. Every helper it called is still used by the // live composition; it called no logic of its own that the route timeline // does not already own. Widget _buildActionButton({ required IconData icon, required Color color, required VoidCallback onTap, }) { return GestureDetector( onTap: onTap, child: Container( width: 38.w, height: 38.w, alignment: Alignment.center, decoration: BoxDecoration( color: color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Icon(icon, color: color, size: 18.sp), ), ); } Widget _buildBulkActionButton() { final selectedCount = _selectedOrders.values.where((s) => s).length; if (selectedCount <= 1) return const SizedBox.shrink(); return Container( width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), color: _primaryRed, boxShadow: [ BoxShadow( color: _primaryRed.withValues(alpha: 0.25), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Material( color: Colors.transparent, child: InkWell( onTap: _hasActivePickup() ? null : _handleBulkAction, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: Padding( padding: EdgeInsets.symmetric(vertical: 14.h), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.zap, color: Colors.white, size: 20.sp), SizedBox(width: 8.w), Text( '${_getBulkButtonLabel()} (${selectedCount})', style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: Colors.white, letterSpacing: 0.3, ), ), ], ), ), ), ), ), ); } // ==================== MAIN BUILD METHOD ==================== @override Widget build(BuildContext context) { super.build(context); // The day, grouped into the slots the hub assigned — see [_trips]. The tab // bar below chooses one of them; [trip] is null when that slot is still // empty, which is its own screen and not "no work". final List trips = _trips; final Trip? trip = _selectedTrip; final bool hasWork = trips.isNotEmpty; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; bool shouldExit = true; try { final prefs = await SharedPreferences.getInstance(); final hasLive = prefs.getBool('has_live_pickup') ?? false; if (hasLive) { final confirm = await showAppDialog( context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), title: Text( 'Pending Pickup', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textPrimary, fontWeight: FontWeight.w700, ), ), content: Text( 'Are you sure you want to close? There are pickups pending.', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text( 'No', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: Text( 'Yes', style: TextStyle( fontFamily: FontConstants.fontFamily, color: _primaryRed, ), ), ), ], ), ); shouldExit = confirm == true; } } catch (_) {} if (shouldExit) { // Home is the root tab — leaving it backgrounds the app. await SystemNavigator.pop(); } }, child: Scaffold( backgroundColor: _surfaceColor, body: Stack( children: [ // ── One scroll view, and the header is a sliver in it ── // // It was a `Column`: the header as a fixed first child, the scroll // view as the second. That pinned the header by construction and // charged its full height — 214pt with the status bar — at every // scroll position, which is why the rider's first stop started // below the fold. // // As a pinned sliver it holds the top of the screen the same way, // and collapses to 52pt of operational context on the way down. // Native `SliverPersistentHeader`; no package was added for it. // See [HomeHeaderDelegate]. AnnotatedRegion( // ── The status bar belongs to the header ── // // On a brand ground the clock and the battery have to invert with // it, or the top 44pt of the phone is dark glyphs on maroon — // which is the one contrast failure a rider cannot work around by // tilting the screen. value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.dark, ), child: RefreshIndicator( onRefresh: _handleRefresh, color: ColorConstants.primary, // The spinner comes down below the pinned header rather than // out from behind it. edgeOffset: MediaQuery.paddingOf(context).top + HomeHeaderDelegate.collapsedContent, child: CustomScrollView( // ── Physics, and why it is not `Bouncing` ── // // `BouncingScrollPhysics` forced the iOS rubber-band on every // platform, and rubber-band overscroll is unbounded — so a // pull dragged the whole page off the top of the screen and // held it against a blank gap. Plain // `AlwaysScrollableScrollPhysics` lets `applyTo` compose with // the platform default (clamping on Android, bouncing on // iOS), while "always scrollable" keeps the pull working on a // short list. physics: const AlwaysScrollableScrollPhysics(), slivers: [ _buildHeaderSliver(context), // ── The trip tabs, under the header rather than in it ── // // They govern what the page below shows, so they belong to // the page. Inside the header they were the one control on // the screen that changes the content, living in the one // surface that never does — and the red underline could // only be white, because it was already on red. // // Not pinned. The header keeps `Trip 1 · 17 left · 33/50` // when it collapses, which is what a rider scrolling needs; // three tabs held permanently would be the same information // twice, in the space the work is supposed to be in. // The tabs are no longer a sliver of their own. They are // the head of the panel that holds the trip, and that panel // is built with the work below — see the `else` branch. // ══════════════════════════════════════════════════════════ // READING ORDER // // [app bar] fixed ← greeting, duty, 1·2·3 // trip summary scrolls away (ring, slot, load, metrics) // hint line scrolls // HUB → stops → HUB scrolls // accept bar scrolls // // Nothing in this scroll view is pinned any more. Everything here // is reference material the rider reads once and then works from // memory, so all of it is allowed to leave. The one control that // governs the whole screen — which trip he is looking at — moved // into the header, which is fixed by being the Column's first // child rather than by being a sliver. See [TripSelector]. // ══════════════════════════════════════════════════════════ // The trip brief used to sit here, above the tabs. It now sits // below them, inside the per-trip block — see the note at its new // call site. // // The standalone shift-window strip that used to sit above it is // gone too: it showed the rider's shift while the card below it // showed the *trip's* slot under the caption "CURRENT SHIFT", so // two different time ranges sat stacked with one wearing the // other's name. Both now live inside [TripBriefStrip] as adjacent // labelled rows. See that class for the rest of the reasoning. // ── Trip 1 · 2 · 3 ── // // Gone from here. It was a pinned // `SliverPersistentHeader` on the canvas, in a // strip of its own between the brand bar and the // page — a third surface down the top of the // screen whose only job was to hold a control. It // is inside the header now, which was already // pinned, so the strip, its hairline and its height // arithmetic all went with it. See [TripSelector]. // The stops, an empty state for an unassigned slot, or — until // the first fetch lands — the shape of a route being loaded. // // One swap holding all of them, so the hand-off between them is // a cross-fade instead of one sliver being swapped for another // and the screen going briefly blank in between. // // ── Why the sliver type changes with the state ── // // With nothing to list, the message *is* the page, and it // belongs in the middle of it. A box adapter shrink-wraps its // child, so a Center inside one only centres within the // artwork's own height — which parked it under the app bar at // the top of an otherwise blank screen. [SliverFillRemaining] // hands it the rest of the viewport to be centred in, and // `hasScrollBody: false` still lets it grow past that on a // short phone rather than clipping. // // A real route keeps the adapter: filling the remainder there // would pad every short trip with a screen of dead space above // the floating-pill floor. if (!hasWork) SliverFillRemaining( hasScrollBody: false, child: SmoothSwap( child: !_queuesLoaded // The real timeline in grey, not three // generic cards — see [HomeRouteSkeleton]. ? const HomeRouteSkeleton( key: ValueKey('home-loading'), ) : _fetchFailed ? Padding( key: const ValueKey('home-error'), padding: EdgeInsets.symmetric(vertical: 32.h), child: ErrorRetry(onRetry: _handleRefresh), ) // ── A day with no work says so ── // // This rendered `SizedBox.shrink()` — // literally nothing — so a rider whose hub // had not assigned him anything opened Home // to a blank screen and could not tell it // apart from a screen that had failed to // load. The empty state already existed and // had simply been orphaned when the trip tabs // came out; it states which of the two this // is, and offers the one action that changes // it when he is off duty. : KeyedSubtree( key: const ValueKey('home-empty'), child: _buildEmptyState(), ), ), ) // ── The panel is the page ── // // Tabs and trip used to be two slivers: a control in one // box, the work it governs in the next, with nothing but // adjacency to say they were related. The panel encloses // the run now — the selected tab is the top edge of the // brand surface the brief, the route and the action bar all // sit inside, so which trip is on screen is said once, by // the shape holding it. // // Still not pinned. The header keeps // `Trip 1 · 17 left · 33/50` when it collapses, which is // what a rider scrolling needs; a tab strip held // permanently would be the same information twice, in the // space the work is supposed to be in. // ── The tabs are attached to the trip's figures ── // // Tabs and brief used to be two boxes: a control in one // sliver, the figures it governs in the next, with nothing // but adjacency to say they were related. They are one // panel now — the selected tab is the top edge of the glass // the brief sits on, so which trip is on screen is said // once, by the shape holding it. // // The route and the action bar stay outside it, on the // canvas with their own white panel. A version of this put // the whole page inside the panel and it was too much: the // ground a rider reads a route off should be the quietest // thing on the screen, not the loudest. // // Still not pinned. The header keeps // `Trip 1 · 17 left · 33/50` when it collapses, which is // what a rider scrolling needs; a tab strip held // permanently would be the same information twice, in the // space the work is supposed to be in. else ...[ SliverToBoxAdapter( // Air above, and the panel takes the same gutter as // every other layer-1 surface on Home. The inset inside // it is [TripSelector.contentInset], so the brief's copy // lands on the same vertical as `TODAY'S RUN` below. child: Padding( padding: EdgeInsets.fromLTRB( MilerSurface.panelGutter, 12.h, MilerSurface.panelGutter, 0, ), child: TripSelector( trips: trips, selectedIndex: _selectedTripIndex, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, // Selection is per-trip, so the ticks do not travel // with the rider between trips — a tick made on // Trip 1 must not be counted by the bar while Trip 2 // is on screen. See [_selectedPendingIds]. onSelect: (index) { // His choice from here on, even if it is an empty // slot — see [_settleTripTab]. _tripTabChosen = true; if (index == _selectedTripIndex) return; setState(() { _selectedTripIndex = index; _selectedStopIds.clear(); }); }, // ── An unfilled slot gets the same panel ── // // It used to get a different one: a line of its own // reading `Not assigned yet`, which made the panel // a 32pt stub under a tab twice its height, and // changed the shape of the screen depending on // whether the hub had got to this slot yet. // // The brief of an empty slot is a real brief — it // is just all zeroes. `0 stops left`, an empty // track, and an em dash under every figure that has // nothing to report. So the panel is the same panel // at the same height, and when the hub assigns the // trip the figures fill in where the dashes were // instead of the page changing shape around the // rider. body: SmoothSwap( child: Reveal( // Keyed on the trip, so switching tabs plays // the entrance once and the 5s poll never // replays it. The empty slot keys on its own // index for the same reason. key: ValueKey( 'brief_${trip?.id ?? 'unassigned-$_selectedTripIndex'}', ), // The panel above is the surface and it is the // brand bar's red, so the brief drops its own // card and inverts its ink. child: _tripCardFor( trip ?? _unassignedTrip(), ).buildSummary(onBrand: true), ), ), ), ), ), // The route and the action bar, on the canvas. SliverToBoxAdapter( child: SmoothSwap( // ── A slot the hub has not filled ── // // The day has work, just not on this tab. Says so by // name rather than reusing the whole-day empty state, // which would claim he has nothing to do while another // trip is running. child: trip == null ? KeyedSubtree( key: ValueKey( 'trip-empty-$_selectedTripIndex', ), child: _buildTripEmptyState( _selectedTripIndex, ), ) : Reveal( key: ValueKey('reveal_${trip.id}'), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // The "one thing to do now" head that // used to sit here is gone. It drew the // current place, its load and a // full-width **Navigate to pickup** — all // of which the timeline's own group node // below already carries, button included. // Two identical red buttons for one // destination was the single largest // thing on this screen; the node that owns // the journey owns the action. _tripCardFor(trip).buildStops(), _tripCardFor(trip).buildActionBar(), ], ), ), ), ), ], // ── Room for everything that floats over this list ── // // 80 was measured against the accepted pill alone. Two // more things pin to the same corner — the selection // bar and the live-pickup banner — and with a banner up // the last stop card was covered by it with no way to // scroll clear, which is what a rider reported as "the // card is cut off". // // The floor now clears the nav bar plus the tallest // stack that can sit on it, so the end of the route is // always reachable. // ── Measured, not assumed ── // // This was a flat `80.h`. The two surfaces that can // occupy the bottom slot now declare their own // heights, and the floor reserves the taller of the // two plus the device inset — so the last route row // clears whichever one is showing, at any text // scale, without anybody re-measuring by eye. SliverToBoxAdapter( child: SizedBox( height: (SelectionBar.maxHeight > AcceptedPill.maxHeight ? SelectionBar.maxHeight : AcceptedPill.maxHeight) + BottomPage.bottomInset(context), ), ), ], ), ), ), // ── The bottom overlay ── // // Only one thing floats over the foot of Home now. The red "Active // Pickup" strip that used to share this column is gone: it was a // second object describing a stop the list below was hiding, it // covered the last card, and its tap did nothing but refetch. The // live stop is a row in the trip now, marked LIVE and carrying its // own way back in — see `TripCard._showsOnHome`. Positioned( left: 0, right: 0, bottom: 0, // ── The command surface arrives, it does not appear ── // // Ticking a row swaps one floating object for another in the same // slot, and swapping them in a single frame reads as the page // flickering rather than as a mode changing. A short rise-and-fade // — the app's own state duration, no bounce — is what says "this // came up because of what you just did". child: AnimatedSwitcher( duration: DesignConstants.motionState, switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: SlideTransition( position: Tween( begin: const Offset(0, 0.35), end: Offset.zero, ).animate(anim), child: child, ), ), child: Column( key: ValueKey(_selectionCount > 0), mainAxisSize: MainAxisSize.min, children: [ if (_selectionCount > 0) Builder( builder: (context) { final action = _selectionAction(); return SelectionBar( count: _selectionCount, action: action, busy: _acceptingTripId == 'selection' || _busyStopIds.isNotEmpty, onClear: () => setState(() => _selectedStopIds = {}), onAccept: _acceptingTripId != null ? null : () => _acceptSelectedStops( _selectedPendingIds().toList(), ), // Arrived / picked up run against the whole ticked // set in one slide — see [_advanceSelection]. onAdvance: _advanceSelection, // A stop already taken on cannot be declined from // here, so Reject leaves with the accept rung. onReject: action != SelectionAction.accept || _acceptingTripId != null ? null : () => _rejectSelectedStops( _selectedPendingIds().toList(), ), ); }, ) // ── The hinge is not here ── // // START DELIVERY used to sit in this slot, and it was in the // wrong place. Home is the *collection* workspace: a stop // leaves it the moment it is in the rider's hands. So the // control that starts the round lived on a screen the round's // orders had already left — and the count it showed was the // only trace of them anywhere in the app. // // It belongs where the load is, which is the deliveries tab. // See the bar at the foot of `pickups.dart`. else // Only when nothing is ticked. The two share this slot on // purpose — see [AcceptedPill] — and a live selection is // the more urgent of the two. // ── No SafeArea here ── // // [BottomPage.bottomInset] already includes // `MediaQuery.padding.bottom`, so wrapping the pill in a // `SafeArea` added the gesture bar's inset a second time // and floated it ~34pt clear of the nav on the phones // that have one. The inset is owned in one place; a // second opinion about it is what makes a floating // control drift between devices. AcceptedPill( // ── Derived, not counted twice ── // // This read `_acceptedStops.length` — the length of a // persisted list of accepted bookings. That store is // written on accept and cleaned on completion, so any // stop that left the day some other way (the hub // pulling it, a reject that raced a refetch, a run // that simply ended) stayed in it, and the pill // counted work the rider no longer had. It is a // second source of truth for a number the trip // already knows. // // The trip is that source: today's stops, resolved // against the same accepted/rejected sets every other // surface on this screen reads. Accepting two shows // two; rejecting one drops it in the same frame, // because the set it is derived from changed. count: _acceptedWaitingCount, // Moves the shell's IndexedStack rather than // rebuilding it, so Bookings keeps its state and the // jump lands. onTap: () => BottomPage.goToTab(1), ), ], ), ), ), // The status-bar scrim that used to sit here is gone. It was a white // strip pinned over the status bar, and it existed for exactly one // reason: the header scrolled, so content slid up under the clock and // battery icons and made them unreadable. Now that the app bar is // pinned — white, and already padded by `MediaQuery.padding.top` — // nothing can reach that strip, and a second white rectangle painted // over a white bar every frame is just cost. ], ), ), ); } } // ==================== BOTTOM SHEETS ==================== // The "Trip Map" sheet (`_showmapDetailsSheet`) that lived here is deleted: // zero callers — its job (map + booking facts for one stop) has belonged to // [StopDetailSheet] since the timeline rework, and it still carried the // pre-redesign idiom: red-tinted bordered boxes, a 36pt cancel icon, and // 'Booking: #id' as the subtitle of a sheet a rider opens to see a map. Widget _buildDetailRow(String label, String value) { final Color primaryRed = ColorConstants.primary; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), // crossAxisAlignment.start keeps the label top-aligned when a long value // (e.g. a full pickup address) wraps onto several lines. child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( color: ColorConstants.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, ), ), const SizedBox(width: 12), // Expanded + soft-wrap: the value takes the remaining width and flows // onto the next line instead of overflowing off the right edge. Expanded( child: Text( value, textAlign: TextAlign.right, softWrap: true, style: TextStyle( color: value.contains('Arrived') || value.contains('Picked') || value.contains('Accepted') ? primaryRed : ColorConstants.onSurface, fontWeight: FontWeight.w600, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, height: 1.35, ), ), ), ], ), ); } void _showProductDetailsSheet( BuildContext context, [ Map? item, ]) { final data = item ?? const {}; String val(List keys) { for (final k in keys) { final v = data[k]; if (v != null && v.toString().trim().isNotEmpty && v.toString() != '0') { return v.toString().trim(); } } return ''; } // Build the rows that actually have data. // What the parcel IS leads; the reference is last — it is the one line the // rider never decides anything on, read only when the office asks for it. final rows = >[ MapEntry( 'Product', val(['description', 'productname', 'product', 'tenantname']), ), MapEntry('Quantity', val(['Quantity', 'quantity'])), MapEntry('Weight', () { final w = val(['weight', 'Weight']); return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg'); }()), MapEntry('Amount', () { final a = val(['collectionamt', 'pickupamt']); return a.isEmpty ? '' : '₹$a'; }()), MapEntry('Pickup point', val(['pickupcustomer', 'tenantname'])), MapEntry('Address', val(['pickupaddress', 'PickupAddress'])), MapEntry('Contact', val(['pickupcontactno'])), MapEntry('Notes', val(['notes', 'Notes'])), MapEntry('Order ID', val(['orderid', 'OrderId'])), ].where((e) => e.value.isNotEmpty).toList(); // Through the kit. The old sheet drew "Product Details" beside a 32pt red // cancel IconButton (the handle and the barrier already dismiss), and boxed // the rows in a bordered panel — a container inside a container, on the one // page family that has none. The rows are a plain ruled list now, and an // empty payload is one quiet sentence rather than a 40pt-padded red box. showMilerSheet( context, builder: (context) => MilerSheetScaffold( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const MilerSheetHeader( title: 'Product details', icon: LucideIcons.package, ), SizedBox(height: 14.h), if (rows.isEmpty) Padding( padding: EdgeInsets.symmetric(vertical: 1.h), child: Text( 'No product details on this booking.', textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ) else for (int i = 0; i < rows.length; i++) ...[ _buildDetailRow(rows[i].key, rows[i].value), if (i != rows.length - 1) Divider(height: 12, color: ColorConstants.borderSubtle), ], ], ), ), ), ); } /// ───────────────────────────────────────────────────────────────────────── /// THE ARRIVED PRECEDENCE RULE /// /// Whether the local ARRIVED record is allowed to speak for a row the server /// has already described. /// /// ── A whitelist, because the blacklist leaked ── /// /// This was three exclusions — not complete, not cancelled, not rejected — /// and `isWorkComplete` is line-dependent: on a milk run it means *delivered /// or cancelled* only. So a server row reporting `picked`, `outForDelivery` /// or `deliveryArrived` passed all three tests and the local record wrote /// `arrived` straight over it. That is the one thing this store must never /// do — it would walk a stop the hub knows is on the road back to a rung the /// rider left an hour ago. /// /// The rule inverted: the local record may speak **only** where the server /// has nothing further along to say. Everything the server does say wins, /// including `arrived` itself, where writing it again is a no-op. /// /// This is the whole of the precedence contract, in one place, so it can be /// asserted directly rather than inferred from three negations. **Delete it /// with the store** — see [getArrivedOrderIds] — once `reached` persists. abstract final class HomepageArrivalPrecedence { static bool localArrivalMayShow(StopStatus serverSaid) => serverSaid.isPending || serverSaid == StopStatus.accepted || serverSaid == StopStatus.arrived || // A row whose status the app could not parse is a row the server has // said nothing usable about, which is exactly the gap this store fills. serverSaid == StopStatus.unknown; }