Files
2026-08-11 13:16:33 +05:30

4091 lines
158 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<bool> 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<Homepage> createState() => _HomepageState();
}
class _HomepageState extends State<Homepage>
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<PickupController>();
Map<String, Map<String, dynamic>> _ordersMap = {};
List<String> _orderIds = [];
StreamSubscription<void>? _pollerSubscription;
Duration? _currentPollInterval = const Duration(seconds: 0);
bool _isToggling = false;
Map<String, bool> _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<String, bool> _pickupRunning = <String, bool>{};
final Map<String, Timer> _pickupTimers = <String, Timer>{};
final Map<String, Map<String, dynamic>> _pickupBasePayload =
<String, Map<String, dynamic>>{};
final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider();
final Map<String, bool> _statusBusy = <String, bool>{};
Timer? _locationTimer;
final Set<String> _hiddenOrderIds = <String>{};
List<Map<String, dynamic>> _activePickups = <Map<String, dynamic>>[];
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<Map<String, dynamic>> _acceptedStops = <Map<String, dynamic>>[];
/// 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<String> _busyStopIds = <String>{};
/// Every stop the hub assigned today, in any state — the source the trip
/// cards are built from. See the note in `_fetchQueues`.
List<Map<String, dynamic>> _allTripStops = <Map<String, dynamic>>[];
/// 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<String> _acceptedIds = <String>{};
Set<String> _rejectedIds = <String>{};
/// Which trip tab is open.
int _selectedTripIndex = 0;
/// Stops the rider has ticked, for "accept just these".
Set<String> _selectedStopIds = <String>{};
// ==================== LIFECYCLE METHODS ====================
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
final keys = List<String>.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<ProfileController>()) {
final pc = Get.find<ProfileController>();
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<String, dynamic> 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<void> _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<void> _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<void> _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<Placemark> 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<void> _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<bool> _isNearPickupLocation(Map<String, dynamic> 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<Map<String, bool>> _checkProximityForOrders(
List<String> orderIds,
) async {
final Map<String, bool> results = {};
for (final orderId in orderIds) {
final Booking = _ordersMap[orderId];
results[orderId] = Booking != null
? await _isNearPickupLocation(Booking)
: false;
}
return results;
}
Future<void> _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<bool> _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<Color>(_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<bool> _handleOnlineToggle(bool online) async {
try {
if (!Get.isRegistered<RiderLogController>()) return false;
final ctl = Get.find<RiderLogController>();
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<void> _loadName() async {
String name = '';
try {
if (Get.isRegistered<ProfileController>()) {
final pc = Get.find<ProfileController>();
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<LogController>()) {
Get.find<LogController>().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<void> _loadPickupsToday() async {
try {
final controller = Get.isRegistered<SummaryController>()
? Get.find<SummaryController>()
: 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<void> _callStop(Map<String, dynamic> 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<void> _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<void>.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<void>.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<void> _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<dynamic> items;
try {
items = await _pickupProvider.getPickupQueues();
} catch (e) {
debugPrint('[FETCH_QUEUES] API error: $e');
items = <dynamic>[];
}
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<Map<String, dynamic>>().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<String, Map<String, dynamic>> newOrdersMap = {};
final List<String> newOrderIds = [];
for (final Booking in validOrders) {
final orderId = (Booking['orderid'] ?? '').toString();
if (orderId.isEmpty) continue;
newOrdersMap[orderId] = Booking;
newOrderIds.add(orderId);
}
final Map<String, bool> 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<Map<String, dynamic>> allStops,
Set<String> acceptedIds,
Set<String> rejectedIds,
) {
if (!mounted) return;
final Map<String, Map<String, dynamic>> ordersMap = {};
final List<String> 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<void> _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<dynamic> itemsV2 = [];
try {
itemsV2 = await _pickupProvider.getCurrentPickups();
} catch (e) {
debugPrint('[HOMEPAGE] Error fetching active bookings: $e');
}
final activeOrders = itemsV2.whereType<Map<String, dynamic>>().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<void> _navigateToActivePickup(Map<String, dynamic> 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<String, dynamic> it) async {
final orderId = (it['orderid'] ?? '').toString();
if (orderId.isEmpty) return;
final base = <String, dynamic>{
'logid': 0,
'tenantid': it['tenantid'] ?? 0,
'partnerid': it['partnerid'] ?? 0,
'locationid': it['locationid'] ?? 0,
'orderheaderid': it['orderheaderid'] ?? 0,
'pickupid': it['pickupid'] ?? 0,
'userid': it['userid'] ?? 0,
'orderid': 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<void> _postPickupLog(String orderId) async {
try {
Map<String, dynamic>? 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<Duration> _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<Trip> 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<String> _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<void> _acceptSelectedStops(List<String> 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<void> _rejectSelectedStops(List<String> orderIds) async {
final stops = orderIds
.map((id) => _ordersMap[id])
.whereType<Map<String, dynamic>>()
.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<String?> _askRejectReason(int count) async {
const reasons = <String>[
'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<String>(
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<void> _unrejectStop(Map<String, dynamic> 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<void> _undoAcceptedBatch(List<Map<String, dynamic>> 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<void> _commitRejection(
Map<String, dynamic> 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<PickupsController>()
? Get.find<PickupsController>()
: 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<Map<String, dynamic>> _lastAcceptedBatch = const [];
Future<void> _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<String, bool>.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<PickupsController>()
? Get.find<PickupsController>()
: 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<Map<String, dynamic>> 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<Map<String, dynamic>>.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<PickupsController>()
? Get.find<PickupsController>()
: 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<SystemUiOverlayStyle>(
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<String, dynamic> 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<PickupsController>();
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<Map<String, dynamic>>(
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<Map<String, dynamic>>(
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<bool>(
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 = <String>{};
}),
),
),
// 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 = <String>{}),
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<String, dynamic>? 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<Polyline> polylines = <Polyline>[];
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<String, dynamic>? item,
]) {
final Color primaryRed = ColorConstants.primary;
final Color lightRed = ColorConstants.errorLight;
final data = item ?? const <String, dynamic>{};
String val(List<String> 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<String, String>>[
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,
),
],
],
),
),
],
),
),
);
},
);
}