4091 lines
158 KiB
Dart
4091 lines
158 KiB
Dart
// 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,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|