// 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/apiconstants.dart'; import 'package:miler/views/helpers/constants/Colorconstants.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/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/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/meal_run_mock.dart'; import 'package:miler/data/bag_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/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. static Color get _surfaceColor => ColorConstants.daylightSurface; 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 = {}; Set _rejectedIds = {}; /// Which trip tab is open. int _selectedTripIndex = 0; /// Stops the rider has ticked, for "accept just these". Set _selectedStopIds = {}; // ==================== LIFECYCLE METHODS ==================== @override void dispose() { WidgetsBinding.instance.removeObserver(this); final keys = List.from(_pickupTimers.keys); for (final k in keys) { _stopPickupPosting(k); } _pollerSubscription?.cancel(); _locationTimer?.cancel(); _httpClient.close(); super.dispose(); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _loadName(); _loadPickupsToday(); _ensureLocationPermission(); _ensureCameraPermission(); _startPolling(); 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 ==================== Future<(String lat, String lng)?> _getValidCoordinates() async { try { await _ensureLocationPermission(); Position? pos; try { pos = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.low, timeLimit: const Duration(seconds: 3), ).timeout(const Duration(seconds: 3)); } catch (_) { pos = await Geolocator.getLastKnownPosition(); } 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 the hub 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 against the // rider's configured `pickupradius`. This one is separate: it guards the // *bulk* "mark selected as arrived" action on Home against a hardcoded // 500 m, and it runs before the controller is ever called. // // So it has to honour the same switch, or turning the fence off still // leaves a rider blocked here with a different message and no obvious // reason why one path let him through and the other did not. That switch is // currently OFF — see [kGeofenceEnforced] for why and how to restore it. 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, ); return distanceInMeters <= 500; } 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.w600, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), ), ), ], ), content: Text( specificMessage ?? 'You must be within 500 meters 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.w600, 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.w600, ), ), 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); 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(); } } 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(); // 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(); // 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. final acceptedStops = 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 = 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. 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'; } } // 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; _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; } } }); } catch (e) { debugPrint('[FETCH_QUEUES] Error: $e'); if (mounted) setState(() => _fetchFailed = true); } finally { _isFetchingQueues = false; if (!_queuesLoaded && mounted) { setState(() => _queuesLoaded = true); } } } /// 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(), }; final activeOrders = 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; // A telemetry log about a booking the backend has never issued is a row // nobody can join to anything. The meal line's stops exist only on this // phone until the endpoints land — see [MealRunMock]. if (MealRunMock.active) { debugPrint('[MEAL_MOCK] pickup log not posted for $orderId'); 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(); final secs = prefs.getInt('logseconds'); final int interval = (secs != null && secs > 0) ? secs : 30; return Duration(seconds: interval); } catch (_) { return const Duration(seconds: 30); } } // ==================== 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(_ordersMap[id] ?? {})) .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, ).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 pinned [TripTabsHeader] in `build`. 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. Future _acceptSelectedStops(List orderIds) async { final ids = orderIds.where((id) => _ordersMap.containsKey(id)).toList(); if (ids.isEmpty || _acceptingTripId != null) return; setState(() { _acceptingTripId = 'selection'; _selectedOrders = {for (final id in _orderIds) id: ids.contains(id)}; }); try { await _handleBulkAction(navigateToBookings: false); } 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. Future _advanceStop( Map stop, String nextStatus, ) async { final orderId = (stop['orderid'] ?? '').toString(); if (orderId.isEmpty) return; 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'); if (nextStatus == 'PICKED') return; } // ── 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'; } else { final produced = ConsignmentState.values .where((s) => s.name == dc.lastPivotConsignmentStatus) .firstOrNull; final legacy = produced == null ? '' : ApiConfig.legacyStatusFromConsignment(produced.name); stop['orderstatus'] = legacy.isEmpty ? 'picked' : legacy; if (produced != null) stop['consignmentstatus'] = produced.name; } // 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; 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); } } catch (e) { debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e'); } } /// 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, 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: BagManifest.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, ]; for (final s in loaded) { await _advanceStop( s, rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED', ); } if (rung == StopStatus.arrived) { await saveBagLabels({ for (final line in BagManifest.forGroup(loaded)) line.orderId: line.bag, }); } 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, StopState.active => SelectionAction.pickUp, _ => SelectionAction.accept, }; } Map? _stopById(String orderId) { for (final s in _allTripStops) { if ((s['orderid'] ?? '').toString() == orderId) return s; } return null; } /// 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 // [BagManifest]. lines: BagManifest.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, ]; for (final stop in loaded) { await _advanceStop(stop, action.status); } // ── 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 [BagManifest]. if (action == SelectionAction.pickUp) { await saveBagLabels({ for (final line in BagManifest.forGroup(loaded)) line.orderId: line.bag, }); } 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 == StopState.active) 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 — the hub has been told' : '${stops.length} stops rejected — the hub 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 ? 'The hub needs a reason to re-route it to someone else.' : 'The hub 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 the hub' : '${taken.length} acceptances undone — back with the hub', 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.w600, 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.w700, 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 []; Future _handleBulkAction({bool navigateToBookings = true}) async { final selectedOrderIds = _selectedOrders.entries .where((e) => e.value) .map((e) => e.key) .toList(); if (selectedOrderIds.isEmpty) return; final statuses = selectedOrderIds .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) .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 = []; for (final orderId in selectedOrderIds) { final Booking = _ordersMap[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, ); } 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); // Picked → remove from the accepted store so it stops counting as // an open booking on the Bookings tab, and record it as finished // so it lands on Activity. Both stores, or the stop falls out of // the app entirely. await removeAcceptedBookings([orderId]); await addCompletedBookings([Booking]); } } } catch (e) { debugPrint('[BULK] Error updating Booking $orderId: $e'); success = false; } if (success) { successCount++; } else { debugPrint('[BULK] Failed to update Booking $orderId'); } } // 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 — the hub 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' && !navigateToBookings) { 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; } if (bulkSucceeded && targetStatus == 'ACCEPTED' && navigateToBookings) { AppFeedback.successGlobal( 'Moved to your ${ServiceProfile.active.workTabLabel}', ); Get.offAll(() => const BottomPage(initialIndex: 1)); } } /// 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, ); /// 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, riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ), 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. Widget _buildAppBar() { final String firstName = _userName.isNotEmpty ? _userName.split(' ').first : "Rider"; return 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: Container( decoration: const BoxDecoration( // ── A branded header, not a coloured rectangle ── // // It wore the same 7% frosted wash as the other three tabs, flattened // against white. That is right for a tab you pass through; Home is // the screen the rider *lives* on, and it is the one place the app // gets to say whose app it is without spending a logo on it. // // Solid brand, and everything on it inverts: the greeting, the duty // word, the switch, the status-bar glyphs. Nothing is tinted — white // on `#960019` clears AA at every size used here, and a "mostly // white" header text would be the one thing on the bar that looks // like a mistake. color: ColorConstants.primary, ), child: SafeArea( // Inset handled explicitly below via MediaQuery so the status bar // never overlaps the header, regardless of nested-Scaffold quirks. top: false, bottom: false, child: Padding( // Top gap went 8 → 18 → 28. At 8 the greeting sat almost against // the status bar, which was tolerable while the header scrolled // away but not now that it is pinned there all day — a fixed bar // with no air above it reads as clipped rather than as a bar. 18 // was still tight against a taller left-hand avatar, and it has to // match the inset [MilerAppBar] uses on the other three tabs or the // headings jump vertically as the rider switches tab. // // Bottom inset is the bar's own breathing room rather than a gap // before the shift row, so it stays tight at 12. // 28 → 20 above, 12 → 16 below. On white the extra top inset was // stopping the greeting reading as clipped against the status bar; // on a solid ground the bar *is* the status bar's surface, so the // gap above can close and the type sits in a balanced band instead // of being pushed to its floor. padding: EdgeInsets.fromLTRB( 20.w, MediaQuery.of(context).padding.top + 20.h, 16.w, 16.h, ), child: Column( children: [ // ── Identity row ── // // The slot at the left used to hold the Doormile mark, on every // tab. The logo is gone from all of them now — the rider knows // which app he opened — and Home is the one screen that has // something better to put there: him. [MilerAvatar] draws his // profile photo, or the first letter of his name when he has // not set one, at exactly the size the mark occupied so the row // keeps its proportions. // // It also gives the greeting a subject: face, then "Hi, Suriya", // then the duty toggle that governs his shift — one row that is // entirely about the person using the app. Row( children: [ MilerAvatar(name: _userName, onBrand: true), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // ── An eyebrow the greeting can lead ── // // "Hi, Suriya" alone was one line floating in a band // with a switch. Naming the line of work above it // gives the header a subject and a predicate — this // is a *shift*, and here is whose — and it is the one // place the tenant's operation is stated at all. Text( ServiceProfile.active.label.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.eyebrow.copyWith( color: Colors.white.withValues(alpha: 0.72), ), ), SizedBox(height: 3.h), // Greeting + name is the anchor of the screen, so it // gets the heaviest type in the header. Text( "Hi, $firstName", maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 22.sp, fontWeight: FontWeight.w700, height: 1.1, letterSpacing: -0.5, color: ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, ), ), ], ), ), SizedBox(width: 12.w), _buildStatusToggle(), ], ), ], ), ), ), ), ); } /// ── Duty control ── /// /// A switch, because that is what a boolean the rider flips twice a day is. /// /// It has been three things. First a skeuomorphic switch — gradient thumb, /// two drop shadows, a glowing pulse dot — whose thumb slid LEFT for on and /// RIGHT for off, backwards from every switch ever made. Then a single /// button, which showed one word and left the rider guessing whether it was /// his state or the thing tapping would do. Then a two-segment ON | OFF /// control, which answered that but reads as a form input: two boxes of equal /// weight, neither of which looks like the live thing it controls. /// /// This is the Material 3 switch — a 52×32 track whose fill IS the state, a /// handle that grows and carries a tick when it lands on, and a word beside /// it so nothing depends on colour alone. It travels left→right into "on" the /// way every switch the rider owns does, and the whole row is the target, not /// just the 52pt track. /// /// **Locked** while a pickup is live: going off duty mid-stop would strand a /// customer. The handle takes a padlock and the row dims rather than the /// control vanishing, so the state stays readable. Widget _buildStatusToggle() { final bool locked = _hasActivePickup(); final bool on = isOnline; // ── Both states on screen, all the time ── // // This was a hand-rolled M3 switch: a track, a travelling handle, and the // word for the CURRENT state beside it. Two problems a rider has on a bike. // A bare switch is a *guess* — "is this the on position or the off one?" — // answered only by remembering which side means what; and the word changed // as he pressed it, so the label he was reading and the control he was // aiming at both moved. // // `AnimatedToggleSwitch.dual` (pub.dev, ~2k likes, actively maintained) is // the shape this control always wanted: a pill holding the state as a word // AND a knob whose position says the same thing a second time, sliding // between them. Nothing is inferred from a position alone, and the package // brings the rolling indicator, the touch-target floor and the disabled // state with it rather than this file growing its own. // // On the brand ground it stays monochrome. Green-on-maroon is the one // pairing this palette cannot make: the two are close in luminance, so a // green "on" read as a smudge. **On** is a solid white knob carrying a // brand tick — the strongest mark available here, and the live state is // correctly the filled one. **Off** is a hollow white knob on a translucent // track: unlit, not absent. Neither depends on hue. return Semantics( toggled: on, enabled: !locked, label: locked ? 'On duty, locked while a pickup is running' : on ? 'On duty. Switch off to stop receiving trips.' : 'Off duty. Switch on to receive trips.', excludeSemantics: true, child: AnimatedToggleSwitch.dual( current: on, first: false, second: true, // Locked mid-pickup: going off duty would strand a customer. The // package dims rather than removing it, so the state stays readable. active: !locked, inactiveOpacity: 0.55, height: 34.h, spacing: 6.w, borderWidth: 1.5, indicatorSize: Size.fromWidth(30.w), animationDuration: const Duration(milliseconds: 260), animationCurve: Curves.easeOutCubic, style: ToggleStyle( backgroundColor: on ? Colors.white.withValues(alpha: 0.18) : Colors.white.withValues(alpha: 0.12), borderColor: Colors.white.withValues(alpha: on ? 0.85 : 0.40), indicatorColor: Colors.white, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), indicatorBorderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), ), iconBuilder: (value) => Icon( locked ? LucideIcons.lock : value ? LucideIcons.check : LucideIcons.pause, size: 15.sp, color: ColorConstants.primary, ), textBuilder: (value) => Padding( padding: EdgeInsets.symmetric(horizontal: 2.w), child: Text( value ? 'On duty' : 'Off duty', maxLines: 1, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: Colors.white, fontFamily: FontConstants.fontFamily, ), ), ), // The confirmation sheet owns the decision — see // [_confirmOnlineOffline]. The switch never changes duty by itself. onChanged: locked ? null : (_) => _confirmOnlineOffline(), ), ); } /// 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: 280.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.w700, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 6.h), Text( 'Stay on duty — new orders appear here the moment ' 'the hub assigns them.', textAlign: TextAlign.center, style: TextStyle( fontSize: 13.5.sp, height: 1.45, fontWeight: FontWeight.w500, 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.w600, 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.w600, 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.w600, 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.w600, ), ), 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: [ // ── Pinned bar, scrolling route ── // // A Column, not a single scroll view: the app bar is the Column's // first child and the scrollable takes the rest, which is what fixes // the bar in place. It was previously the first sliver *inside* the // scroll view, so it scrolled away with everything else — taking the // duty toggle with it. Column( children: [ _buildAppBar(), // ── Pull to refresh ── // // Home was the only tab without this. Bookings, Earnings, Cart and // Notifications all had a RefreshIndicator; here a pull just // dragged the whole page — header included — down off the top of // the screen, held it against a blank surface-coloured gap, and // sprang back having done nothing. That is not a missing feature // so much as a broken-looking one: the gesture responded, so it // read as a refresh that had failed. // // Physics is half the fix. `BouncingScrollPhysics` forced the iOS // rubber-band on every platform, and rubber-band overscroll is // unbounded — hence the gap in the screenshot. Passing plain // `AlwaysScrollableScrollPhysics` lets `applyTo` compose it with // the platform default instead (clamping on Android, bouncing on // iOS), so the overscroll is bounded where it should be, while // "always scrollable" keeps the pull working on a short list. // // `edgeOffset` is gone with the bar: the scroll view now starts // below it, so the spinner already comes down into clear space // instead of out from behind the status bar. Expanded( // ── The corner has to have something behind it ── // // The sheet was clipping straight onto the Scaffold, whose // background is this same `_surfaceColor` — so the two // rounded corners revealed the page's own colour and the // curve was invisible. Home read as a hard maroon line above // a form while the other three tabs had the shape. // // The other three get theirs from `backgroundColor: // primary` on the Scaffold; Home cannot, because its Stack // paints other things on that ground. So the brand is laid // directly behind the sheet instead — the same result, scoped // to the strip that needs it. child: ColoredBox( color: ColorConstants.primary, child: MilerSheet( color: _surfaceColor, child: RefreshIndicator( onRefresh: _handleRefresh, color: ColorConstants.primary, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ // ══════════════════════════════════════════════════════════ // READING ORDER // // [app bar] fixed // shift window scrolls away // trip summary scrolls away (ring, slot, load, metrics) // ─ TRIP 1 · 2 · 3 ─ PINNED // hint line scrolls // HUB → stops → HUB scrolls // accept bar scrolls // // The tab bar is the only thing between the summary and the stops, // and it is the only pinned sliver. Everything above it — the // shift window, the completion ring, the duration and distance — // is reference material the rider reads once and then works from // memory, so it is allowed to leave. The tabs are a control that // governs the whole screen, so they stay. See [TripTabsHeader]. // ══════════════════════════════════════════════════════════ // 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, pinned ── // // Only shown once the day has at least one trip in it: with // nothing assigned the bar would be three empty controls above a // "you have no bookings" panel, which reads as a broken screen // rather than an idle one. if (hasWork) SliverPersistentHeader( pinned: true, delegate: TripTabsHeader( trips: trips, selectedIndex: _selectedTripIndex, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, extent: TripTabsHeader.extentFor(context), // Selection is per-trip, so the ticks do not travel // with the rider between tabs — a tick made on Trip 1 // must not be counted by the bar while Trip 2 is on // screen. See [_selectedPendingIds]. onSelect: (index) { if (index == _selectedTripIndex) return; setState(() { _selectedTripIndex = index; _selectedStopIds.clear(); }); }, ), ), // 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(), ), ), ) else 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( // Keyed on the trip, so switching tabs plays // the entrance once and the 5s poll never // replays it. key: ValueKey('reveal_${trip.id}'), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // The brief sits UNDER the tabs, not above. // // It describes one trip, and the tabs are what // choose that trip — so above them it was // answering a question the rider had not been // asked yet, and switching tabs changed a // panel he had already scrolled past. Below // them it reads as the selected tab's own // content, which is what it is, and it lands // inside the per-trip [Reveal] so it swaps // with the route it belongs to. _tripCardFor(trip).buildSummary(), // 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.w500, 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.w500, 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: 16.h), child: Text( 'No product details on this booking.', textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, 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), ], ], ), ), ), ); }