// ignore_for_file: unused_element, unused_import import 'package:flutter/material.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/Dashboard/pickups/stop_verify.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_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/homepage_banner.dart'; 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/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/widget/Bottom_page.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 = {}; 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() { showModalBottomSheet( context: context, sheetAnimationStyle: kMilerSheetStyle, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (ctx) => Padding( padding: EdgeInsets.only( left: 24, right: 24, top: 20, bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ const Spacer(), IconButton( tooltip: 'Close', icon: Icon(Icons.close, color: _textSecondary), onPressed: () => Navigator.of(ctx).pop(), ), ], ), Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: _lightRed, shape: BoxShape.circle, ), child: Icon(Icons.location_off, size: 64, color: _primaryRed), ), const SizedBox(height: 16), Text( "Location Services Required", style: TextStyle( fontSize: FontConstants.xxLarge(context), fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), ), const SizedBox(height: 8), Text( "Please enable location services to receive bookings and track pickups.", textAlign: TextAlign.center, style: TextStyle( fontSize: FontConstants.regular(context), fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), const SizedBox(height: 24), MilerButton( label: "Enable location", color: _primaryRed, onPressed: () async { Navigator.of(ctx).pop(); await Geolocator.openLocationSettings(); }, ), ], ), ), ); } // ==================== PROXIMITY METHODS ==================== Future _isNearPickupLocation(Map Booking) async { 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(Icons.location_off, color: _primaryRed, size: 28), const SizedBox(width: 12), Expanded( child: Text( 'Not at Pickup Location', style: TextStyle( fontSize: FontConstants.xLarge(context), fontWeight: FontWeight.bold, 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.bold, 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.bold, ), ), 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(); // Stops parked for a return visit. They are Activity's now — see the // stamping loop below and `TripCard._showsOnHome`. final skippedIds = await getSkippedOrderIds(); // 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']); 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] 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; _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 { final now = DateTime.now(); final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; List itemsV2 = []; try { itemsV2 = await _pickupProvider.getCurrentPickups(); } catch (e) { debugPrint('[HOMEPAGE] Error fetching active bookings: $e'); } final activeOrders = itemsV2.whereType>().where(( Booking, ) { final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '') .trim(); return status == 'active'; }).toList(); if (mounted) { setState(() { _activePickups = activeOrders; }); } } catch (e) { debugPrint('[HOMEPAGE] Error fetching active pickup: $e'); } } Future _navigateToActivePickup(Map pickup) async { if (!mounted) return; debugPrint('[HOMEPAGE] Navigate to active pickup: ${pickup['orderid']}'); if (mounted) { await Future.delayed(const Duration(milliseconds: 500)); final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userid'); if (userId != null) { await _fetchQueues(); } } } // ==================== PICKUP LOG METHODS ==================== void _startPickupPosting(Map it) async { final orderId = (it['orderid'] ?? '').toString(); if (orderId.isEmpty) return; final base = { 'logid': 0, 'tenantid': it['tenantid'] ?? 0, 'partnerid': it['partnerid'] ?? 0, 'locationid': it['locationid'] ?? 0, 'orderheaderid': it['orderheaderid'] ?? 0, 'pickupid': it['pickupid'] ?? 0, 'userid': it['userid'] ?? 0, 'orderid': orderId, 'orderstatus': 'picked', }; _pickupBasePayload[orderId] = base; try { final prefs = await SharedPreferences.getInstance(); await prefs.setString( 'pickuplog_${orderId}_tenantid', (base['tenantid'] ?? 0).toString(), ); await prefs.setString( 'pickuplog_${orderId}_partnerid', (base['partnerid'] ?? 0).toString(), ); await prefs.setString( 'pickuplog_${orderId}_locationid', (base['locationid'] ?? 0).toString(), ); await prefs.setString( 'pickuplog_${orderId}_orderheaderid', (base['orderheaderid'] ?? 0).toString(), ); await prefs.setString( 'pickuplog_${orderId}_pickupid', (base['pickupid'] ?? 0).toString(), ); await prefs.setString( 'pickuplog_${orderId}_userid', (base['userid'] ?? 0).toString(), ); await prefs.setString('pickuplog_${orderId}_orderid', orderId); await prefs.setString( 'pickuplog_${orderId}_orderstatus', (base['orderstatus'] ?? 'picked').toString(), ); } catch (_) {} await _postPickupLog(orderId); final interval = await _getLogInterval(); _pickupTimers[orderId]?.cancel(); _pickupTimers[orderId] = Timer.periodic(interval, (_) { _postPickupLog(orderId); }); } Future _postPickupLog(String orderId) async { try { Map? base = _pickupBasePayload[orderId]; if (base == null) { try { final prefs = await SharedPreferences.getInstance(); base = { 'logid': 0, 'tenantid': int.tryParse( prefs.getString('pickuplog_${orderId}_tenantid') ?? '0', ) ?? 0, 'partnerid': int.tryParse( prefs.getString('pickuplog_${orderId}_partnerid') ?? '0', ) ?? 0, 'locationid': int.tryParse( prefs.getString('pickuplog_${orderId}_locationid') ?? '0', ) ?? 0, 'orderheaderid': int.tryParse( prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0', ) ?? 0, 'pickupid': int.tryParse( prefs.getString('pickuplog_${orderId}_pickupid') ?? '0', ) ?? 0, 'userid': int.tryParse( prefs.getString('pickuplog_${orderId}_userid') ?? '0', ) ?? 0, 'orderid': prefs.getString('pickuplog_${orderId}_orderid') ?? orderId, 'orderstatus': prefs.getString('pickuplog_${orderId}_orderstatus') ?? 'picked', }; } catch (_) {} } if (base == null) return; final coords = await _getValidCoordinates(); final now = DateTime.now(); final logdate = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; final payload = { ...base, 'logdate': logdate, 'latitude': (coords?.$1 ?? '0'), 'longitude': (coords?.$2 ?? '0'), }; await _pickupLogProvider.createPickupLog(payload); } catch (e) { debugPrint('[COLLECTIONLOG][POST] error: $e'); } } Future _getLogInterval() async { try { final prefs = await SharedPreferences.getInstance(); 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. /// /// 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( _allTripStops, hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); /// 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() { final trips = _trips; if (trips.isEmpty) return const []; final i = _selectedTripIndex.clamp(0, trips.length - 1); return trips[i] .pendingOrderIds(acceptedIds: _acceptedIds, rejectedIds: _rejectedIds) .where(_selectedStopIds.contains); } int get _selectionCount => _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); }); } } } void _toggleStopSelection(String orderId) { setState(() { final next = {..._selectedStopIds}; next.contains(orderId) ? next.remove(orderId) : next.add(orderId); _selectedStopIds = next; }); } /// 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', ]; return showModalBottomSheet( context: context, sheetAnimationStyle: kMilerSheetStyle, isScrollControlled: true, backgroundColor: ColorConstants.pureSurface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (ctx) => SafeArea( child: Padding( padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 16.h), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40.w, height: 4.h, decoration: BoxDecoration( color: ColorConstants.borderStrong, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), ), ), SizedBox(height: 16.h), Text( count == 1 ? 'Why are you rejecting this stop?' : 'Why are you rejecting these $count stops?', style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w800, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 4.h), Text( 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.', style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), 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(); 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. void _showStopSnack( String message, Color color, { String? actionLabel, VoidCallback? onAction, Duration duration = const Duration(seconds: 4), }) { if (!mounted) return; final messenger = ScaffoldMessenger.of(context); // Only one at a time. Two accepts in quick succession would otherwise // queue, and the rider would be looking at an UNDO belonging to the stop // before the one he is thinking about. messenger.hideCurrentSnackBar(); messenger.showSnackBar( SnackBar( content: Text( message, style: const TextStyle( fontFamily: FontConstants.fontFamily, color: Colors.white, ), ), backgroundColor: color, behavior: SnackBarBehavior.floating, // Clear of the floating nav bar. // // A floating snackbar sits 16 above the bottom of *this* Scaffold, and // this Scaffold runs the full height of the screen — the tab bar is a // sibling layer painted over it by [BottomPage]. So the default margin // put the snackbar underneath the bar, and the action sits at the // snackbar's right edge, which is exactly where the frosted panel is // opaque. An UNDO the rider cannot see or press is worse than no undo // at all: he taps the bar's tab instead and walks away believing the // acceptance is reversed. margin: EdgeInsets.fromLTRB( 16.w, 0, 16.w, BottomPage.bottomInset(context) + 8.h, ), duration: duration, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), action: (actionLabel == null || onAction == null) ? null : SnackBarAction( label: actionLabel, textColor: Colors.white, onPressed: onAction, ), ), ); } /// 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, ); // Optimistic, mirroring the single-accept path: persist EVERY // selected booking regardless of the per-call network result, so // all of them reliably land on the Bookings tab. Previously this // was gated on `success`, so if some accept calls failed only a // subset reached Bookings even though the user accepted all. _hiddenOrderIds.add(orderId); acceptedBookings.add(Booking); } 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, 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); } _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; }); // Accepts persist optimistically, so treat the batch as succeeded when // anything was accepted even if some network calls failed. bulkSucceeded = successCount > 0 || acceptedBookings.isNotEmpty; await _fetchQueues(); // 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'); } 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. _showStopSnack( n == 1 ? 'Stop accepted' : '$n stops accepted', ColorConstants.acceptGreen, actionLabel: taken.isEmpty ? null : 'UNDO', onAction: taken.isEmpty ? null : () => _undoAcceptedBatch(taken), duration: const Duration(seconds: 8), ); return; } if (bulkSucceeded && targetStatus == 'ACCEPTED' && navigateToBookings) { AppFeedback.successGlobal('Moved to your Bookings'); 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, 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), // 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( value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, statusBarBrightness: Brightness.light, ), child: Container( decoration: BoxDecoration( // The same frosted brand wash the other tabs' bars wear, flattened // against white. Home's header is a pinned Container rather than an // AppBar — nothing scrolls behind it, so there is nothing for a real // blur to work on — but the colour has to match [milerGlassSurface] // exactly or switching tabs would change the tint at the top of the // screen. Composited from the same token, so it cannot drift. color: Color.alphaBlend( ColorConstants.glassRed, ColorConstants.pureSurface, ), ), 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. padding: EdgeInsets.fromLTRB( 16.w, MediaQuery.of(context).padding.top + 28.h, 16.w, 12.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), SizedBox(width: 10.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // 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: 23.sp, fontWeight: FontWeight.w800, height: 1.1, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), SizedBox(width: 10.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; // M3 switch geometry. Fixed rather than `.w`-scaled: a switch that changes // size with the design scale stops reading as the same object between // devices, and the thumb travel is computed from these. const double trackW = 52; const double trackH = 32; final double handle = on ? 24 : 16; final Color trackFill = on ? _successColor : ColorConstants.neutralLight; final Color trackEdge = on ? _successColor : ColorConstants.borderStrong; final Color handleFill = on ? Colors.white : ColorConstants.borderStrong; 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.', child: Opacity( opacity: locked ? 0.55 : 1, child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), child: InkWell( onTap: locked ? null : _confirmOnlineOffline, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), child: Padding( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 8.h), child: Row( mainAxisSize: MainAxisSize.min, children: [ // The word carries the state for anyone who cannot rely on // the fill — sunlight, colour vision, a scratched screen. Text( on ? 'On duty' : 'Off duty', style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w800, letterSpacing: -0.2, color: on ? _successColor : ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(width: 8.w), AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, width: trackW, height: trackH, padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: trackFill, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), border: Border.all(color: trackEdge, width: 2), ), child: AnimatedAlign( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, // Left for off, right for on. The first version of this // control had it the other way round. alignment: on ? Alignment.centerRight : Alignment.centerLeft, child: AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, width: handle, height: handle, alignment: Alignment.center, decoration: BoxDecoration( color: handleFill, shape: BoxShape.circle, ), child: locked ? Icon( Icons.lock_rounded, size: 13.sp, color: _successColor, ) : on ? Icon( Icons.check_rounded, size: 15.sp, color: _successColor, ) : null, ), ), ), ], ), ), ), ), ), ); } /// 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; return Transform.translate( offset: const Offset(0, -20), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (offline) // 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, ), ) else // "No results" Lottie, tinted to the maroon brand theme. SizedBox( width: 210.w, height: 180.w, child: ColorFiltered( colorFilter: ColorFilter.mode( _primaryRed, BlendMode.modulate, ), child: Lottie.asset( 'assets/lotties/no_bookings.json', fit: BoxFit.contain, repeat: true, ), ), ), SizedBox(height: 12.h), Text( offline ? "You're Offline" : "No Bookings Available", textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), ), SizedBox(height: 8.h), Text( offline ? "Go online to start receiving bookings" : "We'll notify you when a new booking arrives", textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, fontFamily: FontConstants.fontFamily, color: _textSecondary, ), ), if (offline) ...[ SizedBox(height: 18.h), ElevatedButton.icon( onPressed: _hasActivePickup() ? null : _confirmOnlineOffline, icon: Icon(Icons.bolt_rounded, size: 18.sp), label: Text( "Go Online", style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), style: ElevatedButton.styleFrom( backgroundColor: _primaryRed, foregroundColor: Colors.white, elevation: 0, // Mid-size: content-hugging but with a comfortable min width, // so it's neither full-width nor a tiny chip. The height is // the unscaled [ButtonSizes.compact] rather than `44.h` — // ScreenUtil would shrink a scaled 44 below the tap-target // floor on a short phone, which is where it matters most. minimumSize: Size(150.w, ButtonSizes.compact), tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: EdgeInsets.symmetric( horizontal: 22.w, vertical: 11.h, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ButtonSizes.radius), ), ), ), ], ], ), ), ); } Widget _buildBookingCard(String orderId, Map item) { // First-mile milk-run: this card is a single pickup stop on the route. final title = (item['pickupcustomer'] ?? 'Pickup point').toString(); final address = (item['pickupaddress'] ?? 'Pickup address unavailable') .toString(); final int quantity = int.tryParse('${item['Quantity'] ?? item['quantity'] ?? 0}') ?? 0; final isSelected = _selectedOrders[orderId] ?? false; final currentStatus = _getStatusFromOrder(item); return Container( key: ValueKey('order_$orderId'), margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 5.h), decoration: BoxDecoration( color: _cardColor, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all( color: Colors.black.withValues(alpha: 0.05), width: 1, ), boxShadow: const [ BoxShadow( color: Color(0x0D000000), blurRadius: 24, offset: Offset(0, 8), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.fromLTRB(16.r, 12.r, 16.r, 2.r), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Header: type badge + order id + select ── Row( children: [ Container( padding: EdgeInsets.symmetric( horizontal: 9.w, vertical: 4.h, ), decoration: BoxDecoration( color: _primaryRed.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.inventory_2_rounded, size: 12.sp, color: _primaryRed, ), SizedBox(width: 5.w), Text( 'PICKUP', style: TextStyle( fontSize: 10.sp, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: _primaryRed, fontFamily: FontConstants.fontFamily, ), ), ], ), ), SizedBox(width: 8.w), Expanded( child: Text( '#$orderId', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w600, letterSpacing: 0.3, color: _textTertiary, fontFamily: FontConstants.fontFamily, ), ), ), GestureDetector( onTap: _hasActivePickup() ? null : () { setState(() { _selectedOrders[orderId] = !isSelected; isAllSelected = _selectedOrders.values.every( (s) => s, ); }); }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 24.w, height: 24.w, alignment: Alignment.center, decoration: BoxDecoration( color: isSelected ? _primaryRed : Colors.transparent, border: Border.all( color: isSelected ? _primaryRed : ColorConstants.outlineVariant, width: 2, ), shape: BoxShape.circle, ), child: isSelected ? Icon( Icons.check, color: Colors.white, size: 15.sp, ) : null, ), ), ], ), SizedBox(height: 10.h), // ── Identity: store icon + name + address ── Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 42.w, height: 42.w, alignment: Alignment.center, decoration: BoxDecoration( color: _primaryRed.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: Icon( Icons.storefront_rounded, color: _primaryRed, size: 21.sp, ), ), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 16.5.sp, height: 1.15, color: _textPrimary, fontFamily: FontConstants.fontFamily, letterSpacing: -0.3, ), ), SizedBox(height: 5.h), Row( children: [ Icon( Icons.location_on_rounded, size: 14.sp, color: _textTertiary, ), SizedBox(width: 3.w), Expanded( child: Text( address, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, height: 1.3, color: _textSecondary, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ], ), ), ], ), SizedBox(height: 10.h), // ── Info chip(s) ── Row( children: [ Container( padding: EdgeInsets.symmetric( horizontal: 10.w, vertical: 6.h, ), decoration: BoxDecoration( color: ColorConstants.neutralLight, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.inventory_2_outlined, size: 14.sp, color: _textSecondary, ), SizedBox(width: 6.w), Text( quantity > 0 ? '$quantity items' : 'Parcel', style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w600, color: _textPrimary, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ], ), // Call + Details moved into the single action bar below // (OrderStatusRow) so the card no longer stacks two full-width // button rows. See leadingActions on OrderStatusRow. ], ), ), Row( children: [ Expanded( child: OrderStatusRow( key: ValueKey('status_${orderId}_$currentStatus'), currentStatus: currentStatus, // Secondary actions folded into the same action bar as // Reject/Accept so the card shows ONE row of controls, not two. onCall: () async { final phone = (item['pickupcontactno'] ?? '') .toString() .trim(); if (phone.isEmpty) { if (!context.mounted) return; AppFeedback.error(context, 'Contact number not available'); return; } await launchPhoneDialer(phone); }, onDetails: () => _showProductDetailsSheet(context, item), // Enable single-booking actions whenever the rider is NOT in // multi-select mode (0 selected, or only this one selected). // Previously this required ticking the checkbox first, so a // plain tap on Accept did nothing. enabled: (() { final selectedCount = _selectedOrders.values .where((s) => s) .length; return (selectedCount == 0 || (selectedCount == 1 && isSelected)) && !(_statusBusy[orderId] ?? false); })(), onStatusChange: (newStatus, {String? notes, String? proofImagePath}) async { debugPrint( '[SINGLE] Booking $orderId: $currentStatus -> $newStatus', ); if (_statusBusy[orderId] == true) return false; if (mounted) setState(() => _statusBusy[orderId] = true); final pickupId = int.tryParse('${item['pickupid'] ?? 0}') ?? 0; final orderHeaderId = int.tryParse('${item['orderheaderid'] ?? 0}') ?? 0; final pickupLocationId = int.tryParse('${item['pickuplocationid'] ?? 0}') ?? 0; final dc = Get.find(); bool success = false; try { if (newStatus == 'ACCEPTED') { debugPrint( '[SINGLE] updateAcceptedStatus pickupId=$pickupId', ); success = await dc.updateAcceptedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, notes: notes ?? '', ); } else if (newStatus == 'ARRIVED') { debugPrint( '[SINGLE] updateArrivedStatus pickupId=$pickupId', ); final pickupLat = _parseDouble( item['pickuplat'] ?? item['PickupLat'] ?? 0, ); final pickupLng = _parseDouble( item['pickuplon'] ?? item['PickupLon'] ?? 0, ); final riderLoc = await _getValidCoordinates(); success = await dc.updateArrivedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, ridersLat: riderLoc?.$1 ?? '0', ridersLng: riderLoc?.$2 ?? '0', pickupLat: pickupLat.toString(), pickupLng: pickupLng.toString(), notes: notes ?? '', ); if (success) { if (mounted) { final verifyResult = await openSheet>( context, StopVerificationPage(pickup: item), ); if (verifyResult != null && verifyResult['verified'] == true && mounted) { // Only stand between the rider and the next stop // when there is actually money owed. The old // screen opened on every stop and invented an // amount from the typed weight, so a prepaid // consignment still demanded ₹30. if (stopCollectionAmount(item) > 0) { await openSheet>( context, CollectPaymentPage( pickup: item, verificationData: verifyResult, ), ); } if (!mounted) return false; String? proofImageUrl; if (verifyResult['parcelImage'] != null) { try { proofImageUrl = await dc.uploadProofImage( File(verifyResult['parcelImage']), 'picked', (await SharedPreferences.getInstance()) .getInt('userid') ?? 0, pickupId, ); } catch (e) { debugPrint('Error uploading proof image: $e'); } } final verifyPickupLat = _parseDouble( item['pickuplat'] ?? item['PickupLat'] ?? 0, ); final verifyPickupLng = _parseDouble( item['pickuplon'] ?? item['PickupLon'] ?? 0, ); final verifyRiderLoc = await _getValidCoordinates(); await dc.updatePickedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, pickupLocationId: pickupLocationId, ridersLat: verifyRiderLoc?.$1 ?? '0', ridersLng: verifyRiderLoc?.$2 ?? '0', pickupLat: verifyPickupLat.toString(), pickupLng: verifyPickupLng.toString(), notes: '', proofImage: proofImageUrl, ); if (mounted) { _startPickupPosting(item); setState(() { _hiddenOrderIds.add(orderId); _ordersMap.remove(orderId); _orderIds.remove(orderId); _selectedOrders.remove(orderId); _statusBusy.remove(orderId); }); await _fetchQueues(); dc.triggerRefresh(); } } } } } else if (newStatus == 'PICKED') { debugPrint( '[SINGLE] updatePickedStatus pickupId=$pickupId', ); String? proofImageUrl; if (proofImagePath != null) { try { proofImageUrl = await dc.uploadProofImage( File(proofImagePath), 'picked', (await SharedPreferences.getInstance()).getInt( 'userid', ) ?? 0, pickupId, ); debugPrint( '[SINGLE] Picked Proof URL: $proofImageUrl', ); } catch (e) { debugPrint('Error uploading proof image: $e'); } } final pickupLat = _parseDouble( item['pickuplat'] ?? item['PickupLat'] ?? 0, ); final pickupLng = _parseDouble( item['pickuplon'] ?? item['PickupLon'] ?? 0, ); final riderLoc = await _getValidCoordinates(); success = await dc.updatePickedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, pickupLocationId: pickupLocationId, ridersLat: riderLoc?.$1 ?? '0', ridersLng: riderLoc?.$2 ?? '0', pickupLat: pickupLat.toString(), pickupLng: pickupLng.toString(), notes: notes ?? '', proofImage: proofImageUrl, ); if (success) { _startPickupPosting(item); _hiddenOrderIds.add(orderId); // A picked booking is done — drop it from the accepted // store so it doesn't resurface as an open booking. await removeAcceptedBookings([orderId]); } } else if (newStatus == 'REJECTED') { debugPrint( '[SINGLE] updateRejectedStatus pickupId=$pickupId notes=${notes ?? ''}', ); success = await dc.updateRejectedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, notes: notes ?? '', ); } else if (newStatus == 'CANCELLED') { debugPrint( '[SINGLE] updateCancelledStatus pickupId=$pickupId notes=${notes ?? ''}', ); final pickupLat = _parseDouble( item['pickuplat'] ?? item['PickupLat'] ?? 0, ); final pickupLng = _parseDouble( item['pickuplon'] ?? item['PickupLon'] ?? 0, ); final riderLoc = await _getValidCoordinates(); success = await dc.updateCancelledStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, ridersLat: riderLoc?.$1 ?? '0', ridersLng: riderLoc?.$2 ?? '0', pickupLat: pickupLat.toString(), pickupLng: pickupLng.toString(), notes: notes ?? '', ); if (success && mounted) { setState(() { _hiddenOrderIds.add(orderId); _ordersMap.remove(orderId); _orderIds.remove(orderId); _selectedOrders.remove(orderId); _statusBusy.remove(orderId); }); } } // Accepting a pending booking always routes the rider to // their Bookings tab (where the accepted pickup lives), // regardless of the network result — the queues reconcile // on the next poll. This mirrors the app's optimistic UX. if (newStatus == 'ACCEPTED') { if (success) dc.triggerRefresh(); // Persist acceptance so the booking durably moves to the // Bookings tab (and stays off Home) across refetches — // the backend keeps it 'Miler_Assigned' until pickup, so // the local accepted store is what marks it accepted. await addAcceptedBookings([item]); if (mounted) { setState(() { // Remove from Home right away so the pending list // and the "Active Tasks" count drop immediately. _hiddenOrderIds.add(orderId); _ordersMap.remove(orderId); _orderIds.remove(orderId); _selectedOrders.remove(orderId); _statusBusy.remove(orderId); }); // Stay on Home so the rider can keep accepting the // remaining pending bookings back-to-back. The accepted // one already dropped out of the list above and now // lives in the Bookings tab. AppFeedback.successGlobal('Saved to your Bookings — accept more here'); } return success; } // Rejecting a pending booking removes it from Home right // away regardless of the network result (mirrors accept's // optimistic UX); persist it so it stays gone across // refetch/navigation until the demo resets. if (newStatus == 'REJECTED') { if (success) dc.triggerRefresh(); await addRejectedOrderIds([orderId]); if (mounted) { setState(() { _hiddenOrderIds.add(orderId); _ordersMap.remove(orderId); _orderIds.remove(orderId); _selectedOrders.remove(orderId); _statusBusy.remove(orderId); }); AppFeedback.success(context, 'Booking rejected'); } return success; } if (success) { dc.triggerRefresh(); if (newStatus == 'PICKED') { if (mounted) { setState(() { _hiddenOrderIds.add(orderId); _ordersMap.remove(orderId); _orderIds.remove(orderId); _selectedOrders.remove(orderId); _statusBusy.remove(orderId); }); } await _fetchQueues(); } else { if (mounted) { setState(() { final Booking = _ordersMap[orderId]; if (Booking != null) { Booking['orderstatus'] = newStatus .toLowerCase(); } _statusBusy[orderId] = false; }); } await _fetchQueues(); if (mounted && newStatus == 'ARRIVED') { AppFeedback.success(context, 'Arrived at pickup location'); } } } else { debugPrint( '[SINGLE] API call failed for Booking $orderId', ); if (mounted) { setState(() => _statusBusy[orderId] = false); AppFeedback.error(context, 'Could not update the status — try again'); } } } catch (e) { debugPrint( '[SINGLE] Error updating Booking $orderId: $e', ); if (mounted) { setState(() => _statusBusy[orderId] = false); AppFeedback.error(context, 'Could not update the status — try again'); } } return success; }, ), ), ], ), ], ), ); } 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( Icons.flash_on_rounded, color: Colors.white, size: 20.sp, ), SizedBox(width: 8.w), Text( '${_getBulkButtonLabel()} (${selectedCount})', style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: Colors.white, letterSpacing: 0.3, ), ), ], ), ), ), ), ), ); } // ==================== MAIN BUILD METHOD ==================== @override Widget build(BuildContext context) { super.build(context); // Home is trip-shaped: the hub assigns a whole slot, not loose bookings. final trips = _trips; // Trip 2 and Trip 3 exist as tabs before the hub assigns them, so the index // is clamped to the TAB count, not the trip count — an index past the end // is a legitimate "not assigned yet" slot, not an error. final tabCount = trips.length > TripTabs.maxInlineTabs ? trips.length : TripTabs.maxInlineTabs; final selectedTrip = _selectedTripIndex.clamp(0, tabCount - 1); 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.bold, ), ), 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( 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 · Trip 2 · Trip 3, pinned ── SliverPersistentHeader( pinned: true, delegate: TripTabsHeader( extent: TripTabsHeader.extentFor(context), trips: trips, selectedIndex: selectedTrip, acceptedIds: _acceptedIds, rejectedIds: _rejectedIds, onSelect: (i) => setState(() { _selectedTripIndex = i; // Selection belongs to one trip; carrying ticks across // tabs would let an "accept selected" touch stops the // rider can no longer see. _selectedStopIds = {}; }), ), ), // The stops, an empty state for an unassigned slot, or — until // the first fetch lands — the shape of a route being loaded. // // One adapter holding all three, 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. SliverToBoxAdapter( child: SmoothSwap( child: trips.isEmpty && !_queuesLoaded ? const SkeletonList( key: ValueKey('home-loading'), count: 3, ) : trips.isEmpty && _fetchFailed ? Padding( key: const ValueKey('home-error'), padding: EdgeInsets.symmetric( vertical: 32.h, ), child: ErrorRetry(onRetry: _handleRefresh), ) : selectedTrip >= trips.length ? KeyedSubtree( key: ValueKey('home-empty-$selectedTrip'), child: _buildTripEmptyState(selectedTrip), ) : Reveal( // Keyed on the trip so switching tabs plays the // entrance once per trip, and the 5s poll never // replays it. key: ValueKey( 'reveal_trip_${trips[selectedTrip].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( trips[selectedTrip], ).buildSummary(), _tripCardFor( trips[selectedTrip], ).buildStops(), _tripCardFor( trips[selectedTrip], ).buildActionBar(), ], ), ), ), ), // 6. Bottom padding for bulk action button SliverToBoxAdapter(child: SizedBox(height: 80.h)), ], ), ), ), ], ), // ── The bottom overlays, stacked rather than layered ── // // Both of these pin to the foot of the screen, and before this they // were separate `Positioned(bottom: 0)` children — which meant that // the moment a rider with a live pickup ticked a stop, one covered // the other. A single bottom-anchored column lets them coexist: the // selection bar takes the lower slot because it is the thing the // rider is acting on right now, and the ambient banner rides above. Positioned( left: 0, right: 0, bottom: 0, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_activePickups.isNotEmpty) SafeArea( top: false, bottom: _selectionCount == 0, child: ActivePickupBanner( activePickups: _activePickups, onTap: (pickup) async { await _navigateToActivePickup(pickup); if (mounted) { await Future.delayed( const Duration(milliseconds: 500), ); final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userid'); if (userId != null) { await _fetchQueues(); } } }, ), ), if (_selectionCount > 0) SelectionBar( count: _selectionCount, busy: _acceptingTripId == 'selection', onClear: () => setState(() => _selectedStopIds = {}), onAccept: _acceptingTripId != null ? null : () => _acceptSelectedStops( _selectedPendingIds().toList(), ), onReject: _acceptingTripId != null ? null : () => _rejectSelectedStops( _selectedPendingIds().toList(), ), ) 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. SafeArea( top: false, child: AcceptedPill( count: _acceptedStops.length, // 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 ==================== void _showmapDetailsSheet( BuildContext context, Map? item, ) async { final Color primaryRed = ColorConstants.primary; final Color lightRed = ColorConstants.errorLight; double parseD(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } final String pickupLatStr = (item?['pickuplat'] ?? item?['PickupLat'] ?? '') .toString(); final String pickupLngStr = (item?['pickuplon'] ?? item?['PickupLon'] ?? '') .toString(); final String orderId = (item?['orderid'] ?? '').toString(); final String statusStr = (item?['orderstatus'] ?? '').toString(); final String pickupAddress = (item?['Pickupaddress'] ?? item?['pickuplocation'] ?? '').toString(); double pickupLat = parseD(pickupLatStr); double pickupLng = parseD(pickupLngStr); bool hasPickup = pickupLat != 0 && pickupLng != 0; double riderLat = 0.0; double riderLng = 0.0; bool hasRiderLocation = false; try { Position? pos; try { pos = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 8), ); } catch (e) { pos = await Geolocator.getLastKnownPosition(); } if (pos != null) { riderLat = pos.latitude; riderLng = pos.longitude; hasRiderLocation = true; } } catch (e) {} double distanceMeters = 0.0; String distanceKm = '0.00'; if (hasRiderLocation && hasPickup) { distanceMeters = Geolocator.distanceBetween( riderLat, riderLng, pickupLat, pickupLng, ); distanceKm = (distanceMeters / 1000).toStringAsFixed(2); } final bool hasAny = hasRiderLocation && hasPickup; final List polylines = []; final MapController mapController = MapController(); if (hasRiderLocation && hasPickup) { // OSRM instead of the Directions API — free, and no key to be revoked. final coords = await MilerRouter.route( LatLng(riderLat, riderLng), LatLng(pickupLat, pickupLng), ); polylines.add( Polyline( // A straight line when the router cannot be reached, so the map still // says which way the stop is rather than showing nothing at all. points: coords.isNotEmpty ? coords : [LatLng(riderLat, riderLng), LatLng(pickupLat, pickupLng)], color: primaryRed, strokeWidth: 5, strokeCap: StrokeCap.round, strokeJoin: StrokeJoin.round, pattern: coords.isNotEmpty ? const StrokePattern.solid() : StrokePattern.dashed(segments: const [20, 12]), ), ); } showModalBottomSheet( context: context, sheetAnimationStyle: kMilerSheetStyle, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) { return Container( margin: const EdgeInsets.only(top: 50), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), blurRadius: 20, offset: const Offset(0, -8), ), ], ), child: Padding( padding: EdgeInsets.only( left: 24, right: 24, top: 12, bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 56, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: ColorConstants.outlineVariant, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Trip Map", style: TextStyle( fontSize: 22.sp, fontWeight: FontWeight.bold, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), ), Text( orderId.isNotEmpty ? 'Booking: #$orderId' : 'Booking: -', style: TextStyle( color: ColorConstants.onSurfaceVariant, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, ), ), ], ), InkWell( onTap: () => Navigator.of(context).pop(), child: Icon( Icons.cancel_rounded, size: 36, color: primaryRed, ), ), ], ), const SizedBox(height: 12), if (hasAny) ...[ Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: lightRed, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all( color: primaryRed.withValues(alpha: 0.2), ), ), child: Row( children: [ Icon(Icons.location_on, color: primaryRed, size: 22), const SizedBox(width: 10), Expanded( child: Text( hasPickup ? "Pickup: ${pickupAddress.isNotEmpty ? pickupAddress : 'Current location'}" : 'Pickup: Not available', style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), ), ), ], ), ), const SizedBox(height: 8), if (distanceKm != '0.00') Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: primaryRed.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all( color: primaryRed.withValues(alpha: 0.2), ), ), child: Row( children: [ Icon(Icons.navigation, color: primaryRed, size: 22), const SizedBox(width: 10), Expanded( child: Text( "Distance to Pickup: $distanceKm km", style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, color: primaryRed, ), ), ), ], ), ), if (distanceKm != '0.00') const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: Container( height: 280, decoration: BoxDecoration( border: Border.all(color: ColorConstants.dividerColor), ), child: MilerMap( controller: mapController, initialCenter: hasRiderLocation && hasPickup ? LatLng( (riderLat + pickupLat) / 2, (riderLng + pickupLng) / 2, ) : hasPickup ? LatLng(pickupLat, pickupLng) : hasRiderLocation ? LatLng(riderLat, riderLng) : const LatLng(0, 0), initialZoom: hasRiderLocation && hasPickup ? 13 : 15, polylines: polylines, markers: [ if (hasPickup) milerMarker( point: LatLng(pickupLat, pickupLng), width: MilerPin.size + 8, height: MilerPin.size + MilerPin.tail, child: MilerPin( color: ColorConstants.primary, icon: Icons.inventory_2_rounded, emphasised: true, ), ), if (hasRiderLocation) milerMarker( point: LatLng(riderLat, riderLng), width: MilerRiderDot.size, height: MilerRiderDot.size, child: const MilerRiderDot(), ), ], onReady: () { // ── The custom Google map style is gone with the plugin ── // // Forty lines of JSON greyed out POIs and tinted the // roads, on a map that never rendered because the key was // revoked. OSM's own cartography is already quiet, and // restyling raster tiles is not something a client can do // — that is a tile-server decision now. if (!hasRiderLocation || !hasPickup) return; WidgetsBinding.instance.addPostFrameCallback((_) { mapController.fitCamera( CameraFit.coordinates( coordinates: [ LatLng(riderLat, riderLng), LatLng(pickupLat, pickupLng), ], padding: const EdgeInsets.all(56), ), ); }); }, ), ), ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: lightRed, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all( color: primaryRed.withValues(alpha: 0.2), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Booking Details", style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), ), const SizedBox(height: 10), _buildDetailRow( "Booking ID:", orderId.isNotEmpty ? "#$orderId" : '-', ), _buildDetailRow( "Status:", statusStr.isNotEmpty ? statusStr : '-', ), _buildDetailRow("Distance:", "$distanceKm km"), _buildDetailRow( "Quantity:", "${item?['Quantity'] ?? item?['quantity'] ?? '-'}", ), ], ), ), ], ], ), ), ); }, ); } 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.w600, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, height: 1.35, ), ), ), ], ), ); } void _showProductDetailsSheet( BuildContext context, [ Map? item, ]) { final Color primaryRed = ColorConstants.primary; final Color lightRed = ColorConstants.errorLight; 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. final rows = >[ MapEntry('Order ID', val(['orderid', 'OrderId'])), 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'])), ].where((e) => e.value.isNotEmpty).toList(); showModalBottomSheet( context: context, sheetAnimationStyle: kMilerSheetStyle, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (context) { return Padding( padding: EdgeInsets.only( left: 24, right: 24, top: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Product Details", style: TextStyle( fontSize: 22.sp, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), ), IconButton( tooltip: 'Dismiss', icon: Icon(Icons.cancel, color: primaryRed, size: 32), onPressed: () => Navigator.pop(context), ), ], ), const SizedBox(height: 16), if (rows.isEmpty) Container( width: double.infinity, padding: const EdgeInsets.all(40), decoration: BoxDecoration( color: lightRed, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all( color: primaryRed.withValues(alpha: 0.2), ), ), child: Column( children: [ Icon( Icons.inventory_2_rounded, size: 48, color: primaryRed.withValues(alpha: 0.3), ), const SizedBox(height: 12), Text( "No product details available", style: TextStyle( fontSize: 16.sp, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ], ), ) else Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), decoration: BoxDecoration( color: ColorConstants.surfaceContainerLow, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all(color: ColorConstants.dividerColor), ), child: Column( children: [ 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.dividerColor, ), ], ], ), ), ], ), ), ); }, ); }