Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4153 lines
168 KiB
Dart
4153 lines
168 KiB
Dart
// ignore_for_file: unused_element, unused_import
|
||
|
||
import 'package:animated_toggle_switch/animated_toggle_switch.dart';
|
||
import 'package:another_flushbar/flushbar.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:lottie/lottie.dart' hide Marker;
|
||
import 'dart:convert';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'dart:async';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'dart:io';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:miler/providers/pickup/pickup_provider.dart';
|
||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||
import 'package:miler/views/Dashboard/orders/orderstaus_button.dart';
|
||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||
import 'package:miler/helpers/poll_policy.dart';
|
||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/controllers/profile_controller.dart';
|
||
import 'package:miler/controllers/riderlog.dart';
|
||
import 'package:miler/controllers/logcontroller.dart';
|
||
import 'package:miler/controllers/duty_controller.dart';
|
||
import 'package:miler/Models/stop_status.dart';
|
||
import 'package:miler/controllers/pickup.dart';
|
||
import 'package:miler/controllers/pickups_controller.dart';
|
||
import 'package:miler/controllers/summary_controller.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import 'package:latlong2/latlong.dart' show LatLng;
|
||
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
||
import 'package:geocoding/geocoding.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:floating/floating.dart';
|
||
import 'dart:math' as math;
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:miler/views/Dashboard/home/accepted_pill.dart';
|
||
import 'package:miler/views/Dashboard/home/selection_bar.dart';
|
||
import 'package:miler/views/Dashboard/home/route_brief.dart';
|
||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_empty_state.dart';
|
||
import 'package:miler/views/Dashboard/home/duty_sheet.dart';
|
||
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
|
||
import 'package:miler/views/Dashboard/pickups/pickups.dart' as pickups;
|
||
import 'package:miler/background/live_tracking_service.dart';
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/api_config.dart';
|
||
import 'package:miler/data/consignment_state.dart';
|
||
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
|
||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||
import 'package:miler/widget/Bottom_page.dart';
|
||
import 'package:miler/data/meal_run_mock.dart';
|
||
import 'package:miler/data/bag_manifest.dart';
|
||
import 'package:miler/data/milk_run.dart';
|
||
import 'package:miler/data/order_events.dart';
|
||
import 'package:miler/data/work_domain.dart';
|
||
import 'package:miler/data/work_repository.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
|
||
import 'package:miler/views/Dashboard/home/route_timeline.dart';
|
||
import 'package:miler/views/Dashboard/home/stop_action_sheet.dart';
|
||
|
||
/// Helper function to launch phone dialer
|
||
Future<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>{};
|
||
|
||
/// Orders the rider is physically carrying, on a service run.
|
||
///
|
||
/// Local only — the parcel backend has no state between "accepted" and
|
||
/// "picked", and `picked` is the delivery. See the collected store.
|
||
Set<String> _collectedIds = <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() {
|
||
// ── Through the sheet kit ──
|
||
//
|
||
// This drew a plain white sheet with its own 24 radius, an oversized 64pt
|
||
// icon in a filled disc, and a Close IconButton row — the one sheet in the
|
||
// app with an X, duplicating both the barrier tap and the drag the kit's
|
||
// handle advertises. The header idiom is the same one every other sheet
|
||
// now uses; the icon is the standard badge size, and the drag handle plus
|
||
// the barrier are how a sheet closes.
|
||
showMilerSheet<void>(
|
||
context,
|
||
builder: (ctx) => MilerSheetScaffold(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const MilerSheetHeader(
|
||
title: 'Turn on location',
|
||
subtitle:
|
||
'Bookings and live tracking need your position. Nothing is '
|
||
'assigned to a rider the hub cannot see.',
|
||
icon: LucideIcons.mapPinOff,
|
||
),
|
||
SizedBox(height: 20.h),
|
||
MilerButton(
|
||
label: 'Enable location',
|
||
onPressed: () async {
|
||
Navigator.of(ctx).pop();
|
||
await Geolocator.openLocationSettings();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==================== PROXIMITY METHODS ====================
|
||
Future<bool> _isNearPickupLocation(Map<String, dynamic> Booking) async {
|
||
// ── The second proximity gate ──
|
||
//
|
||
// `PickupsController._checkGeofence` guards a single stop against the
|
||
// rider's configured `pickupradius`. This one is separate: it guards the
|
||
// *bulk* "mark selected as arrived" action on Home against a hardcoded
|
||
// 500 m, and it runs before the controller is ever called.
|
||
//
|
||
// So it has to honour the same switch, or turning the fence off still
|
||
// leaves a rider blocked here with a different message and no obvious
|
||
// reason why one path let him through and the other did not. That switch is
|
||
// currently OFF — see [kGeofenceEnforced] for why and how to restore it.
|
||
if (kBypassGeofenceForTesting) {
|
||
debugPrint(
|
||
'[GEOFENCE] OFF for bulk arrival — enforcement is disabled in this '
|
||
'build. Stop completion is NOT proximity-verified. '
|
||
'See kGeofenceEnforced.',
|
||
);
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
final pickupLat = _parseDouble(
|
||
Booking['pickuplat'] ?? Booking['PickupLat'],
|
||
);
|
||
final pickupLng = _parseDouble(
|
||
Booking['pickuplon'] ?? Booking['PickupLon'],
|
||
);
|
||
if (pickupLat == 0 || pickupLng == 0) return true;
|
||
final riderLoc = await _getValidCoordinates();
|
||
if (riderLoc == null) return true;
|
||
final double riderLat = double.tryParse(riderLoc.$1) ?? 0;
|
||
final double riderLng = double.tryParse(riderLoc.$2) ?? 0;
|
||
if (riderLat == 0 || riderLng == 0) return true;
|
||
final double distanceInMeters = Geolocator.distanceBetween(
|
||
riderLat,
|
||
riderLng,
|
||
pickupLat,
|
||
pickupLng,
|
||
);
|
||
return distanceInMeters <= 500;
|
||
} catch (e) {
|
||
debugPrint('[PROXIMITY] Error checking proximity: $e');
|
||
return true;
|
||
}
|
||
}
|
||
|
||
Future<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(LucideIcons.mapPinOff, color: _primaryRed, size: 28),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
'Not at Pickup Location',
|
||
style: TextStyle(
|
||
fontSize: FontConstants.xLarge(context),
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textPrimary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: Text(
|
||
specificMessage ??
|
||
'You must be within 500 meters of the pickup location to mark this Booking as arrived.',
|
||
style: TextStyle(
|
||
fontSize: FontConstants.regular(context),
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
style: TextButton.styleFrom(foregroundColor: _primaryRed),
|
||
child: Text(
|
||
'OK',
|
||
style: TextStyle(
|
||
fontSize: FontConstants.regular(context),
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==================== ONLINE/OFFLINE TOGGLE ====================
|
||
//
|
||
// The sheet itself is [DutySheet] — chrome, copy and the slide control. What
|
||
// stays here is the work: the pending-booking gate, the API call and the
|
||
// polling and tracking that follow it.
|
||
void _confirmOnlineOffline() {
|
||
if (_isToggling) return;
|
||
final bool newStatus = !isOnline;
|
||
if (!mounted) return;
|
||
|
||
DutySheet.show(
|
||
context,
|
||
goingOnline: newStatus,
|
||
onConfirm: () => _applyDutyChange(newStatus),
|
||
);
|
||
}
|
||
|
||
/// Carries out the duty change the sheet just asked for.
|
||
///
|
||
/// Returns true when duty actually changed — the sheet uses that to decide
|
||
/// whether to close or to spring its knob back and stay put.
|
||
Future<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.w600,
|
||
),
|
||
),
|
||
content: Text(
|
||
'You are still holding work. Complete or hand back your open '
|
||
'bookings, then you can go off duty.',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(dCtx).pop(),
|
||
child: Text(
|
||
'OK',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _primaryRed,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
} catch (_) {}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!mounted) return false;
|
||
_isToggling = true;
|
||
showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (dialogCtx) => Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.all(22),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
child: SizedBox(
|
||
height: 40,
|
||
width: 40,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 3.2,
|
||
valueColor: AlwaysStoppedAnimation<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();
|
||
final collectedIds = await getCollectedOrderIds();
|
||
// Stops parked for a return visit. They are Activity's now — see the
|
||
// stamping loop below and `TripCard._showsOnHome`.
|
||
final skippedIds = await getSkippedOrderIds();
|
||
// Stops he has already closed today. Same argument as the skips: the
|
||
// rider's own record of what he did beats a queue that has not caught up.
|
||
final completedIds = await getCompletedOrderIds();
|
||
|
||
// Accepted stops have left the Home list for the Bookings tab, but they
|
||
// are still on the rider's route — their parcels are still to be
|
||
// collected and their kilometres still to be ridden. The route brief
|
||
// must count them or it understates the day.
|
||
final acceptedStops = await getAcceptedBookings();
|
||
if (mounted && acceptedStops.length != _acceptedStops.length) {
|
||
setState(() => _acceptedStops = acceptedStops);
|
||
} else {
|
||
_acceptedStops = acceptedStops;
|
||
}
|
||
|
||
// EVERY stop the hub assigned today, whatever its state.
|
||
//
|
||
// Home used to drop a stop the moment it was accepted, which emptied the
|
||
// screen out as the rider worked and made it impossible to show trip
|
||
// progress. A trip is a unit: it stays on Home from assignment until the
|
||
// last stop is resolved, and each stop renders its own state (pending /
|
||
// accepted / active / done / rejected) via `stopStateOf`.
|
||
//
|
||
// Accepted stops still also appear on Bookings — that is where he WORKS
|
||
// them. Home is where he watches the trip fill up.
|
||
final allStops = items.whereType<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']);
|
||
// Finished beats everything: a stop he has closed is closed, whatever
|
||
// the queue still says about it. Without this the row he just picked
|
||
// up stays on Home wearing its LIVE mark until the backend catches up,
|
||
// which reads as the confirm not having worked.
|
||
if (completedIds.contains(id)) {
|
||
s['orderstatus'] = 'picked';
|
||
} else if (skippedIds.contains(id)) {
|
||
s['orderstatus'] = 'skipped';
|
||
} else if (raw == StopStatus.skipped && acceptedIds.contains(id)) {
|
||
s['orderstatus'] = 'accepted';
|
||
}
|
||
}
|
||
|
||
// Still-undecided stops only — the accept/reject actions and the "N
|
||
// pending" counts operate on this narrower set.
|
||
final validOrders = allStops.where((m) {
|
||
final oid = (m['orderid'] ?? '').toString();
|
||
if (oid.isNotEmpty && _hiddenOrderIds.contains(oid)) return false;
|
||
if (oid.isNotEmpty && acceptedIds.contains(oid)) return false;
|
||
if (oid.isNotEmpty && rejectedIds.contains(oid)) return false;
|
||
final st = stopStatusFromRaw(m['orderstatus']);
|
||
return st != StopStatus.active &&
|
||
st != StopStatus.accepted &&
|
||
st != StopStatus.arrived &&
|
||
st != StopStatus.picked &&
|
||
st != StopStatus.cancelled &&
|
||
st != StopStatus.rejected &&
|
||
// A parked stop is not on offer. It was never rendered as a
|
||
// decision row, but it was still counted as one — so "3 stops to
|
||
// accept" included stops the rider had already put down.
|
||
st != StopStatus.skipped;
|
||
}).toList();
|
||
|
||
// Visibility into the pipeline so a "API returned N but Home shows 0" can
|
||
// be diagnosed from logs (online flag, raw count, and survivors).
|
||
debugPrint(
|
||
'[FETCH_QUEUES] line=${ServiceProfile.active.line.name} '
|
||
'online=$online rawFromApi=${items.length} '
|
||
'afterFilters=${validOrders.length} '
|
||
'hidden=${_hiddenOrderIds.length} accepted=${acceptedIds.length} '
|
||
'rejected=${rejectedIds.length}',
|
||
);
|
||
|
||
// Keyed on ALL stops plus the local decision sets: a stop moving from
|
||
// accepted to done changes no pending row, but it does move the trip's
|
||
// completion ring, so the card has to repaint.
|
||
final newJson = jsonEncode({
|
||
'all': allStops,
|
||
'accepted': acceptedIds.toList()..sort(),
|
||
'rejected': rejectedIds.toList()..sort(),
|
||
});
|
||
if (newJson == _lastQueuesJson) {
|
||
return;
|
||
}
|
||
_lastQueuesJson = newJson;
|
||
|
||
final Map<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;
|
||
_collectedIds = collectedIds;
|
||
_ordersMap = newOrdersMap;
|
||
_orderIds = newOrderIds;
|
||
_selectedOrders = newSelection;
|
||
isAllSelected =
|
||
_selectedOrders.isNotEmpty &&
|
||
_selectedOrders.values.every((selected) => selected);
|
||
for (final orderId in _orderIds) {
|
||
if (!_pickupRunning.containsKey(orderId)) {
|
||
_pickupRunning[orderId] = false;
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
debugPrint('[FETCH_QUEUES] Error: $e');
|
||
if (mounted) setState(() => _fetchFailed = true);
|
||
} finally {
|
||
_isFetchingQueues = false;
|
||
if (!_queuesLoaded && mounted) {
|
||
setState(() => _queuesLoaded = true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Applies a set of stops to the trip state. Shared by the normal fetch and
|
||
/// the signed-out demo path.
|
||
void _applyStops(
|
||
List<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 {
|
||
List<dynamic> itemsV2 = [];
|
||
try {
|
||
itemsV2 = await _pickupProvider.getCurrentPickups();
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Error fetching active bookings: $e');
|
||
}
|
||
|
||
// A stop the rider has already closed is not active, whatever this
|
||
// endpoint still says. It lags the confirm by a poll or two, and since
|
||
// these get merged into the trip (see [_tripStops]) a stale row would
|
||
// reappear on Home wearing its LIVE mark seconds after he finished it.
|
||
final closedIds = <String>{
|
||
...await getCompletedOrderIds(),
|
||
...await getSkippedOrderIds(),
|
||
};
|
||
|
||
final activeOrders = itemsV2.whereType<Map<String, dynamic>>().where((
|
||
booking,
|
||
) {
|
||
final status = (booking['orderstatus']?.toString().toLowerCase() ?? '')
|
||
.trim();
|
||
if (status != 'active') return false;
|
||
return !closedIds.contains((booking['orderid'] ?? '').toString());
|
||
}).toList();
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_activePickups = activeOrders;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Error fetching active pickup: $e');
|
||
}
|
||
}
|
||
|
||
/// Opens the map screen for the stop the rider is on, from its row on Home.
|
||
///
|
||
/// This used to be `_navigateToActivePickup`, which — despite the name —
|
||
/// navigated nowhere: it waited half a second and refetched the queue. The
|
||
/// banner that called it was therefore a red strip across the bottom of the
|
||
/// screen whose only visible effect on tap was a spinner. Bookings has had
|
||
/// the real entry point all along; Home just never used it.
|
||
Future<void> _continueStop(Map<String, dynamic> stop) async {
|
||
if (!mounted) return;
|
||
debugPrint('[HOMEPAGE] Continue active stop: ${stop['orderid']}');
|
||
await pickups.MyPickups.navigateToPickupMap(context, stop);
|
||
if (!mounted) return;
|
||
await _fetchQueues();
|
||
}
|
||
|
||
// ==================== PICKUP LOG METHODS ====================
|
||
void _startPickupPosting(Map<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;
|
||
|
||
// A telemetry log about a booking the backend has never issued is a row
|
||
// nobody can join to anything. The meal line's stops exist only on this
|
||
// phone until the endpoints land — see [MealRunMock].
|
||
if (MealRunMock.active) {
|
||
debugPrint('[MEAL_MOCK] pickup log not posted for $orderId');
|
||
return;
|
||
}
|
||
|
||
final coords = await _getValidCoordinates();
|
||
final now = DateTime.now();
|
||
final logdate =
|
||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||
|
||
final payload = {
|
||
...base,
|
||
'logdate': logdate,
|
||
'latitude': (coords?.$1 ?? '0'),
|
||
'longitude': (coords?.$2 ?? '0'),
|
||
};
|
||
|
||
await _pickupLogProvider.createPickupLog(payload);
|
||
} catch (e) {
|
||
debugPrint('[COLLECTIONLOG][POST] error: $e');
|
||
}
|
||
}
|
||
|
||
Future<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.
|
||
///
|
||
// ── Why [_allTripStops] is NOT filtered through [WorkBoundary] ──
|
||
//
|
||
// It was, briefly, and it broke two things at once — worth writing down so
|
||
// nobody re-applies it.
|
||
//
|
||
// `WorkBoundary` answers "which screen **works** this order?". Home answers a
|
||
// different question: "what is my day?". Those are not the same list.
|
||
//
|
||
// • `pickupQueue` drops `WorkDomain.closed`, and this list is documented to
|
||
// keep finished stops — the trip's progress ring needs them as its
|
||
// denominator. Filtering them turned "3 of 8 done" into "3 of 3 done".
|
||
// • On logistics the boundary is *acceptance*, so every accepted booking is
|
||
// `WorkDomain.delivery`. Home still has to show them: they are the rider's
|
||
// run, and the kitchen group expands to list exactly those orders. The
|
||
// filter emptied the group and the dropdown stopped opening.
|
||
//
|
||
// The guarantee the boundary exists for is about **actions**, not about
|
||
// display: Deliveries lists only what `deliveryQueue` admits, and Home's own
|
||
// rungs are gated by `MilkRun.worksOnOwnScreen`. An order can be *visible* on
|
||
// Home as part of the day while being *worked* on Deliveries — what it can
|
||
// never be is actionable in two places.
|
||
|
||
/// Built from [_allTripStops] — every stop in any state, finished ones
|
||
/// included. The finished stops are hidden from the *list* rather than
|
||
/// dropped from the trip (see `TripCard._sequence`), because the trip's
|
||
/// figures are computed from it: strip them here and "2 of 6 stops done"
|
||
/// becomes "0 of 4", and the ring empties itself every time the rider
|
||
/// finishes something.
|
||
List<Trip> get _trips => Trip.groupIntoTrips(
|
||
_tripStops,
|
||
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
hubLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// How much work is waiting on the **other** tab.
|
||
///
|
||
/// The number behind the floating pill, whose tap goes straight to
|
||
/// Deliveries — so it has to be that tab's own count, or the rider is sent to
|
||
/// a screen holding a different number of rows than the badge he pressed.
|
||
///
|
||
/// That is exactly what it used to be: `accepted + collected` over Home's own
|
||
/// view, which on a kitchen line counted a stop from the moment he accepted
|
||
/// it — an hour before anything was in his hands and before Deliveries would
|
||
/// list it at all. The pill said 6, the tab showed 0, and neither was wrong
|
||
/// about its own question.
|
||
///
|
||
/// Asked of [WorkBoundary] now, from the same day and the same sets the other
|
||
/// screen fetches with, so the two cannot disagree.
|
||
int get _acceptedWaitingCount => WorkBoundary.deliveryQueue(
|
||
_tripStops,
|
||
collectedIds: _collectedIds,
|
||
acceptedIds: _acceptedIds,
|
||
).length;
|
||
|
||
/// ── Today's work, as one run ──
|
||
///
|
||
/// Every stop the rider holds today, slot boundaries collapsed. This is what
|
||
/// the *counters* are derived from — the accepted pill has to mean "you are
|
||
/// holding this much", which is a fact about the day and not about whichever
|
||
/// tab happens to be on screen.
|
||
///
|
||
/// The screen itself is per-trip: the hub assigns work in slots, dispatch
|
||
/// rings the rider about "the second trip", and [_trips] is that grouping. See
|
||
/// [_selectedTrip] and the pinned [TripTabsHeader] in `build`.
|
||
Trip get _todayTrip => Trip.fromStops(
|
||
id: 'today',
|
||
stops: _tripStops,
|
||
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
hubLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// The trip the tab bar currently points at, or null when that slot has not
|
||
/// been assigned yet.
|
||
///
|
||
/// Null is a real state, not an error: the tabs deliberately show Trip 2 and
|
||
/// Trip 3 before the hub has filled them, so the rider can see that the day
|
||
/// has room left in it. That case renders [_buildTripEmptyState], not the
|
||
/// whole-day empty state — "nothing in this slot yet" and "you have no work"
|
||
/// are different sentences and only one of them is true while Trip 1 runs.
|
||
Trip? get _selectedTrip {
|
||
final trips = _trips;
|
||
// Slot, not position — see [TripSlots.tripAt]. Slot 1 is the afternoon
|
||
// whether or not there is morning work, so an empty slot is "nothing
|
||
// assigned for that part of the day", not "past the end of the list".
|
||
if (_selectedTripIndex < 0) return null;
|
||
return trips.tripAt(_selectedTripIndex);
|
||
}
|
||
|
||
/// [_allTripStops] plus whatever the rider is actually on.
|
||
///
|
||
/// The two lists come from different endpoints — the queue, and the
|
||
/// current-pickups call behind [_fetchActivePickups] — and the queue does not
|
||
/// always still carry a stop once it has gone active. That was survivable
|
||
/// while the live stop was announced by its own floating banner; now that the
|
||
/// row *is* the announcement, a stop missing from the trip would mean a rider
|
||
/// with a job running and nothing on Home saying so.
|
||
///
|
||
/// Merged by order id, live copy last so it wins on status.
|
||
List<Map<String, dynamic>> get _tripStops {
|
||
if (_activePickups.isEmpty) return _allTripStops;
|
||
final live = <String, Map<String, dynamic>>{
|
||
for (final s in _activePickups)
|
||
if ((s['orderid'] ?? '').toString().isNotEmpty)
|
||
(s['orderid'] ?? '').toString(): s,
|
||
};
|
||
if (live.isEmpty) return _allTripStops;
|
||
|
||
final merged = <Map<String, dynamic>>[];
|
||
for (final s in _allTripStops) {
|
||
final id = (s['orderid'] ?? '').toString();
|
||
merged.add(live.remove(id) ?? s);
|
||
}
|
||
merged.addAll(live.values);
|
||
return merged;
|
||
}
|
||
|
||
/// Ticked stops that are still *this* trip's and still undecided.
|
||
///
|
||
/// Deliberately not just `_selectedStopIds`. That set can outlive the thing
|
||
/// it points at in two ways, and the floating [SelectionBar] would report
|
||
/// both as work waiting to be accepted:
|
||
///
|
||
/// • **A stop stops being pending.** Accepting or rejecting one on its own
|
||
/// row leaves the tick behind, so the count would keep including a stop the
|
||
/// rider has already decided.
|
||
/// • **The rider changes tab.** Selection belongs to one trip; a tick made on
|
||
/// Trip 1 must not be counted while Trip 2 is on screen — see the
|
||
/// `_selectedStopIds` reset in the tab's `onSelect`.
|
||
///
|
||
/// This mirrors exactly what the trip footer computes for its select-all row,
|
||
/// so the bar and the row can never disagree about the number.
|
||
Iterable<String> _selectedPendingIds() =>
|
||
(_selectedTrip?.pendingOrderIds(
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
) ??
|
||
const <String>[])
|
||
.where(_selectedStopIds.contains);
|
||
|
||
/// How many ticks the bar is acting on.
|
||
///
|
||
/// Pending-only for a parcel route, where ticking exists solely to choose
|
||
/// what to accept. A service rider keeps his selection through *arrived* and
|
||
/// *picked up*, so counting only undecided stops would hide the bar the
|
||
/// moment he accepted the very set he is about to walk into a kitchen with.
|
||
int get _selectionCount => ServiceProfile.active.handsOffAtCollection
|
||
? _selectedStopIds.where((id) => _stopById(id) != null).length
|
||
: _selectedPendingIds().length;
|
||
|
||
/// Accepts exactly the stops the rider ticked.
|
||
///
|
||
/// The middle ground between one-at-a-time and all-at-once: on a long trip
|
||
/// he can scan the list, tick the four addresses he knows are open, and take
|
||
/// them in a single action instead of eight taps.
|
||
Future<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);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sends the crate photo for a kitchen load. Never throws at the caller.
|
||
///
|
||
/// A failed upload costs the hub a picture; a failed upload that surfaced as
|
||
/// an error at a counter would cost the rider his place in the queue. So it
|
||
/// is logged and dropped — the load itself is already recorded, and that is
|
||
/// the part the run depends on.
|
||
Future<void> _uploadLoadProof(String path, String kitchenName) async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userId = prefs.getInt('userid') ?? 0;
|
||
if (userId <= 0) return;
|
||
final url = await Get.find<PickupsController>().uploadProofImage(
|
||
File(path),
|
||
'picked',
|
||
userId,
|
||
0,
|
||
);
|
||
debugPrint('[LOAD] $kitchenName crate photo → ${url ?? 'not stored'}');
|
||
} catch (e) {
|
||
debugPrint('[LOAD] crate photo upload failed: $e');
|
||
}
|
||
}
|
||
|
||
/// Moves one stop up the ladder from its row on Home: `ARRIVED`, then
|
||
/// `PICKED`.
|
||
///
|
||
/// ── Where the two rungs actually happen ──
|
||
///
|
||
/// On a parcel route both are at the customer's door and belong to Bookings.
|
||
/// On a service run they are at the **kitchen**: the rider arrives, is handed
|
||
/// the food, and only then does the order turn into a delivery. So the stop
|
||
/// stays on Home for both, and this is what walks it through them.
|
||
///
|
||
/// `PICKED` is the hand-off. It is written to the server — the parcel really
|
||
/// has been collected, which is exactly what the status means — and recorded
|
||
/// locally as collected, which is what moves the card to Bookings.
|
||
Future<void> _advanceStop(
|
||
Map<String, dynamic> stop,
|
||
String nextStatus,
|
||
) async {
|
||
final orderId = (stop['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) return;
|
||
|
||
try {
|
||
final dc = Get.find<PickupsController>();
|
||
final pickupId = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0;
|
||
final orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0;
|
||
final pickupLocationId =
|
||
int.tryParse('${stop['pickuplocationid'] ?? 0}') ?? 0;
|
||
final riderLoc = await _getValidCoordinates();
|
||
final pickupLat = _parseDouble(
|
||
stop['pickuplat'] ?? stop['PickupLat'] ?? 0,
|
||
).toString();
|
||
final pickupLng = _parseDouble(
|
||
stop['pickuplon'] ?? stop['PickupLon'] ?? 0,
|
||
).toString();
|
||
|
||
bool ok = false;
|
||
if (nextStatus == 'ARRIVED') {
|
||
ok = await dc.updateArrivedStatus(
|
||
pickupId: pickupId,
|
||
orderHeaderId: orderHeaderId,
|
||
ridersLat: riderLoc?.$1 ?? '0',
|
||
ridersLng: riderLoc?.$2 ?? '0',
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
);
|
||
} else {
|
||
ok = await dc.updatePickedStatus(
|
||
pickupId: pickupId,
|
||
orderId: orderId,
|
||
orderHeaderId: orderHeaderId,
|
||
pickupLocationId: pickupLocationId,
|
||
ridersLat: riderLoc?.$1 ?? '0',
|
||
ridersLng: riderLoc?.$2 ?? '0',
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
notes: '',
|
||
);
|
||
}
|
||
|
||
// ── A failed write moves nothing ──
|
||
//
|
||
// Every other status write on this screen is optimistic, and for arrival
|
||
// that is right: the rider is standing at a counter, the row is his own
|
||
// report of where he is, and snapping it back because a poll is late
|
||
// reads as the button having done nothing.
|
||
//
|
||
// Pickup-complete is not that kind of write. It is the boundary between
|
||
// the two halves of the app — see [WorkBoundary] — and crossing it on an
|
||
// unconfirmed call is how an order ends up on Deliveries while the
|
||
// backend still has it assigned for collection, which no refresh can
|
||
// repair because the local record outranks the queue by design. So the
|
||
// hand-over waits for the server, and a failure leaves the stop exactly
|
||
// where it is, on Home, still to be picked up.
|
||
if (!ok) {
|
||
debugPrint('[ADVANCE] $orderId -> $nextStatus reported failure');
|
||
if (nextStatus == 'PICKED') return;
|
||
}
|
||
|
||
// ── The rung the SERVER produced, not the one the button is named after ──
|
||
//
|
||
// `picked` was hardcoded here. In compatibility mode `pickup-complete`
|
||
// releases the consignment in the same call, so the server's answer is
|
||
// `Out_for_Delivery` — and stamping `picked` over it put the rider's
|
||
// card one rung behind his own consignment and behind the console, which
|
||
// reads the same state and calls it Active. Two screens, two truths, one
|
||
// stop.
|
||
//
|
||
// `lastPivotConsignmentStatus` is whatever the pivot actually reported.
|
||
// Absent (an older backend, a response that named nothing) it falls back
|
||
// to `picked`, which is the honest reading of "collected, nothing said
|
||
// about release".
|
||
if (nextStatus == 'ARRIVED') {
|
||
stop['orderstatus'] = 'arrived';
|
||
} else {
|
||
final produced = ConsignmentState.values
|
||
.where((s) => s.name == dc.lastPivotConsignmentStatus)
|
||
.firstOrNull;
|
||
final legacy = produced == null
|
||
? ''
|
||
: ApiConfig.legacyStatusFromConsignment(produced.name);
|
||
stop['orderstatus'] = legacy.isEmpty ? 'picked' : legacy;
|
||
if (produced != null) stop['consignmentstatus'] = produced.name;
|
||
}
|
||
|
||
// Written at the one moment it is true. Nothing else records these two:
|
||
// the backend keeps a booking's *current* status and returns no event
|
||
// feed, so without this the rider's own history of his morning is three
|
||
// rungs long. See [OrderEvent].
|
||
await stampOrderEvent(
|
||
orderId,
|
||
nextStatus == 'ARRIVED'
|
||
? OrderEvent.arrivedAtPickup
|
||
: OrderEvent.pickedUp,
|
||
);
|
||
|
||
if (nextStatus == 'PICKED') {
|
||
// Confirmed by the backend, so it is safe to record: this is the write
|
||
// that hands the order over to Deliveries.
|
||
await addCollectedOrderIds([orderId]);
|
||
await addAcceptedBookings([stop]);
|
||
// One copy of the day, and it is now stale — the other screen must not
|
||
// be able to render this order from a cached response that predates the
|
||
// hand-over. See [WorkRepository].
|
||
unawaited(WorkRepository.instance.invalidate());
|
||
if (!mounted) return;
|
||
setState(() => _collectedIds = {..._collectedIds, orderId});
|
||
}
|
||
|
||
// ── When the hub did not hear it, say so ──
|
||
//
|
||
// Once, plainly, and without blocking anything. `reached` currently
|
||
// answers 200 and writes nothing (see
|
||
// [PickupsController.updateArrivedStatus]), so the rider's ARRIVED is
|
||
// his own record and not a shared one — and the first person to find
|
||
// that out should not be an operator wondering why a rider has been at a
|
||
// kitchen for forty minutes.
|
||
final notice = dc.lastArrivalNotice;
|
||
if (nextStatus == 'ARRIVED' && notice != null && mounted) {
|
||
AppFeedback.infoGlobal(notice);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e');
|
||
}
|
||
}
|
||
|
||
/// Walks one stop up its next rung, behind a slide-to-confirm sheet.
|
||
///
|
||
/// ── Why the whole kitchen goes together ──
|
||
///
|
||
/// He arrived at a counter, not at an order. Confirming arrival for one bag
|
||
/// and leaving its two siblings on `accepted` would post three different
|
||
/// states for one event and leave him sliding the same sheet three times at
|
||
/// the same window. So the sheet acts on every stop from that source that is
|
||
/// on the same rung — which is exactly the set the batch bar would have
|
||
/// acted on, reached without having to tick anything.
|
||
///
|
||
/// Collection is different and stays per bag on the group page; this is the
|
||
/// arrival, which is genuinely one event.
|
||
Future<void> _advanceStopWithSheet(Map<String, dynamic> stop) async {
|
||
final state = stopStateOf(
|
||
stop,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
);
|
||
final rung = switch (state) {
|
||
StopState.accepted => StopStatus.accepted,
|
||
StopState.active => StopStatus.arrived,
|
||
_ => null,
|
||
};
|
||
if (rung == null) return;
|
||
|
||
// Siblings at the same counter, on the same rung.
|
||
final batch = [
|
||
for (final s in MilkRun.sameSourceAs(stop, _allTripStops))
|
||
if (stopStateOf(
|
||
s,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
) ==
|
||
state)
|
||
s,
|
||
];
|
||
final ids = batch.map(MilkRun.idOf).where((id) => id.isNotEmpty).toList();
|
||
if (ids.isEmpty) return;
|
||
|
||
final place = rung == StopStatus.accepted
|
||
? MilkRun.navigationLabel(stop)
|
||
: MilkRun.sourceNameOf(stop);
|
||
|
||
final done = await StopActionSheet.show(
|
||
context,
|
||
stage: rung,
|
||
placeName: place,
|
||
count: ids.length,
|
||
lines: BagManifest.forGroup(batch),
|
||
onConfirm: (outcome) async {
|
||
setState(() => _busyStopIds.addAll(ids));
|
||
try {
|
||
final missing = outcome.missingOrderIds.toSet();
|
||
final loaded = [
|
||
for (final s in batch)
|
||
if (!missing.contains(MilkRun.idOf(s))) s,
|
||
];
|
||
for (final s in loaded) {
|
||
await _advanceStop(
|
||
s,
|
||
rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED',
|
||
);
|
||
}
|
||
if (rung == StopStatus.arrived) {
|
||
await saveBagLabels({
|
||
for (final line in BagManifest.forGroup(loaded))
|
||
line.orderId: line.bag,
|
||
});
|
||
}
|
||
if (missing.isNotEmpty) {
|
||
await addNotLoadedOrderIds(missing.toList());
|
||
if (mounted) {
|
||
setState(() {
|
||
for (final id in missing) {
|
||
_hiddenOrderIds.add(id);
|
||
_ordersMap.remove(id);
|
||
_orderIds.remove(id);
|
||
_selectedOrders.remove(id);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if (outcome.photoPath != null) {
|
||
unawaited(_uploadLoadProof(outcome.photoPath!, place));
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('[RUNG] $ids failed: $e');
|
||
return 'Could not update — check your connection and try again';
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.removeAll(ids));
|
||
}
|
||
},
|
||
);
|
||
|
||
if (!done || !mounted) return;
|
||
AppFeedback.successGlobal(
|
||
rung == StopStatus.accepted
|
||
? 'Arrived · ${ids.length} ready to collect'
|
||
: '${ids.length} picked up — deliver them from '
|
||
'${ServiceProfile.active.workTabLabel}',
|
||
);
|
||
await _fetchQueues();
|
||
}
|
||
|
||
/// Opens turn-by-turn navigation for one stop.
|
||
///
|
||
/// The destination is [MilkRun.navigationTarget]'s, not this screen's, so the
|
||
/// row icon, the detail sheet and the stop map cannot disagree about where a
|
||
/// given order is going. Silent when the stop carries no coordinates for its
|
||
/// current leg — the row hides the control in that case, so reaching here
|
||
/// without one means the data changed under a rebuild.
|
||
Future<void> _navigateToStop(Map<String, dynamic> stop) async {
|
||
final target = MilkRun.navigationTarget(stop, collectedIds: _collectedIds);
|
||
if (target == null) {
|
||
if (mounted) {
|
||
AppFeedback.info(context, 'No location on this stop yet');
|
||
}
|
||
return;
|
||
}
|
||
await _openExternalNavigation(target.lat, target.lng);
|
||
}
|
||
|
||
/// Hands the coordinates to the phone's own maps app.
|
||
///
|
||
/// Native intent first, web as the fallback — the same order the stop map
|
||
/// screen uses, so a rider gets the same app whichever control he presses.
|
||
Future<void> _openExternalNavigation(double lat, double lng) async {
|
||
try {
|
||
final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d');
|
||
if (await launchUrl(native, mode: LaunchMode.externalApplication)) return;
|
||
} catch (_) {}
|
||
try {
|
||
await launchUrl(
|
||
Uri.parse(
|
||
'https://www.google.com/maps/dir/?api=1'
|
||
'&destination=$lat,$lng&travelmode=driving',
|
||
),
|
||
mode: LaunchMode.externalApplication,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[NAV] could not open maps: $e');
|
||
}
|
||
}
|
||
|
||
/// The rung the ticked stops are all on, and therefore what the bar offers.
|
||
///
|
||
/// Mixed selections fall back to accepting: that is the only rung that is
|
||
/// safe to run against a set in different states, because it is a no-op on
|
||
/// anything already taken on.
|
||
SelectionAction _selectionAction() {
|
||
if (!ServiceProfile.active.handsOffAtCollection) {
|
||
return SelectionAction.accept;
|
||
}
|
||
final states = <StopState>{
|
||
for (final id in _selectedStopIds)
|
||
if (_stopById(id) != null)
|
||
stopStateOf(
|
||
_stopById(id)!,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
),
|
||
};
|
||
if (states.length != 1) return SelectionAction.accept;
|
||
return switch (states.first) {
|
||
StopState.accepted => SelectionAction.arrive,
|
||
StopState.active => SelectionAction.pickUp,
|
||
_ => SelectionAction.accept,
|
||
};
|
||
}
|
||
|
||
Map<String, dynamic>? _stopById(String orderId) {
|
||
for (final s in _allTripStops) {
|
||
if ((s['orderid'] ?? '').toString() == orderId) return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Walks every ticked stop up one rung, in one gesture.
|
||
///
|
||
/// This is the shape of the job at a kitchen: five parcels come off one
|
||
/// counter in one handover, so the rider ticks the five and confirms once —
|
||
/// not five times, twice. `PICKED` is also the hand-off, so the batch that
|
||
/// finishes it is the batch that lands on the deliveries tab.
|
||
///
|
||
/// ── Two steps, on purpose ──
|
||
///
|
||
/// The bar's button *opens* the confirmation; [StopActionSheet] carries the
|
||
/// slide that commits it. Pressing "Mark as Picked" with a crate in one hand
|
||
/// must not be the thing that writes to the hub — the press is easy to make
|
||
/// by accident, the slide is not, and the sheet is where there is finally
|
||
/// room to say which counter and how many orders he is speaking for.
|
||
Future<void> _advanceSelection() async {
|
||
final action = _selectionAction();
|
||
final ids = _selectedStopIds.toList();
|
||
if (ids.isEmpty || action == SelectionAction.accept) return;
|
||
|
||
final stops = [
|
||
for (final id in ids)
|
||
if (_stopById(id) != null) _stopById(id)!,
|
||
];
|
||
if (stops.isEmpty) return;
|
||
|
||
final arriving = action == SelectionAction.arrive;
|
||
// Where he is. Every stop in a valid selection is on the same rung; when
|
||
// they are also from one counter — the normal case, and the one the rider
|
||
// is standing in — the sheet can name it. A mixed pick says how many
|
||
// places instead of naming the wrong one.
|
||
final places = {
|
||
for (final s in stops)
|
||
arriving ? MilkRun.navigationLabel(s) : MilkRun.sourceNameOf(s),
|
||
}..removeWhere((p) => p.trim().isEmpty);
|
||
final place = places.length == 1 ? places.first : '';
|
||
|
||
final done = await StopActionSheet.show(
|
||
context,
|
||
stage: arriving ? StopStatus.accepted : StopStatus.arrived,
|
||
placeName: place,
|
||
count: ids.length,
|
||
// Named, with the bag each one travels in — this is the list he checks
|
||
// against the shelf in front of him. One order, one bag; see
|
||
// [BagManifest].
|
||
lines: BagManifest.forGroup(stops),
|
||
onConfirm: (outcome) async {
|
||
setState(() => _busyStopIds.addAll(ids));
|
||
try {
|
||
// ── A bag the counter could not supply ──
|
||
//
|
||
// It does not move up the ladder: reporting it as picked would put a
|
||
// lunch on the round that is not in the box. It leaves the route with
|
||
// its own record instead — not a skip and not a cancellation, because
|
||
// nobody was visited and the customer did nothing. This is the
|
||
// missing-item path the separate collect sheet used to own; it lives
|
||
// here now because this is the only place a pickup is confirmed.
|
||
final missing = outcome.missingOrderIds.toSet();
|
||
final loaded = [
|
||
for (final stop in stops)
|
||
if (!missing.contains(MilkRun.idOf(stop))) stop,
|
||
];
|
||
|
||
for (final stop in loaded) {
|
||
await _advanceStop(stop, action.status);
|
||
}
|
||
// ── The pairing is fixed here, and never recomputed ──
|
||
//
|
||
// Joe is Bag 1 of the five that left this counter. Deriving that
|
||
// later from whatever list is on screen breaks it the moment one is
|
||
// delivered, so it is written down at the one moment it is true and
|
||
// read back unchanged for the rest of the round. See [BagManifest].
|
||
if (action == SelectionAction.pickUp) {
|
||
await saveBagLabels({
|
||
for (final line in BagManifest.forGroup(loaded))
|
||
line.orderId: line.bag,
|
||
});
|
||
}
|
||
if (missing.isNotEmpty) {
|
||
await addNotLoadedOrderIds(missing.toList());
|
||
}
|
||
// One shot of the whole load, uploaded against the load rather than
|
||
// any one order. Fire-and-forget: a rider at a hatch must not wait on
|
||
// kitchen wifi, and the pickup is recorded either way.
|
||
if (outcome.photoPath != null) {
|
||
unawaited(_uploadLoadProof(outcome.photoPath!, place));
|
||
}
|
||
// ── Collecting is not setting off ──
|
||
//
|
||
// The release used to ride along with the pickup here, so the moment
|
||
// the rider slid to confirm five bags at a counter the consignments
|
||
// went `Out_for_Delivery` and the hub's board showed five *active*
|
||
// deliveries — for food still on the shelf in front of him. Picked
|
||
// and out-for-delivery are two different claims about where the load
|
||
// is, and only he can make the second one.
|
||
//
|
||
// It moved to where the load is: the START DELIVERY bar at the foot
|
||
// of the deliveries tab. See [MyPickups.startRound].
|
||
if (mounted && missing.isNotEmpty) {
|
||
setState(() {
|
||
for (final id in missing) {
|
||
_hiddenOrderIds.add(id);
|
||
_ordersMap.remove(id);
|
||
_orderIds.remove(id);
|
||
_selectedOrders.remove(id);
|
||
}
|
||
});
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('[RUNG] $ids failed: $e');
|
||
return 'Could not update — check your connection and try again';
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.removeAll(ids));
|
||
}
|
||
},
|
||
);
|
||
|
||
if (!done || !mounted) return;
|
||
// The selection has served its purpose — and on a pick-up it now points at
|
||
// orders that have left this screen for the deliveries tab.
|
||
setState(() => _selectedStopIds = <String>{});
|
||
AppFeedback.successGlobal(
|
||
action == SelectionAction.pickUp
|
||
? '${ids.length} picked up — deliver them from '
|
||
'${ServiceProfile.active.workTabLabel}'
|
||
: 'Arrived · ${ids.length} ready to collect',
|
||
);
|
||
await _fetchQueues();
|
||
}
|
||
|
||
void _toggleStopSelection(String orderId) {
|
||
// Unticking is never blocked — a rider must always be able to back out of
|
||
// a selection he is halfway through.
|
||
if (_selectedStopIds.contains(orderId)) {
|
||
setState(() => _selectedStopIds = {..._selectedStopIds}..remove(orderId));
|
||
return;
|
||
}
|
||
|
||
// ── One kitchen at a time, once he is past accepting ──
|
||
//
|
||
// Acceptance is per order and crosses kitchens freely: he takes on all six
|
||
// of the morning's orders in one press, whichever counter they come from.
|
||
//
|
||
// **Pickup does not.** Arriving and collecting happen at one counter, and a
|
||
// selection spanning two kitchens would post "arrived" for a kitchen the
|
||
// rider is nowhere near and mark bags collected that are still in another
|
||
// shop's warmer. So the moment the ticked set is accepted work, it is bound
|
||
// to a single source.
|
||
//
|
||
// Refused with the reason rather than silently swapping the selection:
|
||
// dropping his existing ticks to honour the newest tap loses work he did
|
||
// deliberately, and he would not see which ones went.
|
||
final incoming = _stopById(orderId);
|
||
if (incoming != null && _isPickupSelection && _selectedStopIds.isNotEmpty) {
|
||
final anchor = _stopById(_selectedStopIds.first);
|
||
if (anchor != null && !MilkRun.sameSource(anchor, incoming)) {
|
||
final anchorName = MilkRun.sourceNameOf(anchor);
|
||
AppFeedback.info(
|
||
context,
|
||
anchorName.isEmpty
|
||
? 'Collect one kitchen at a time — finish this pickup first'
|
||
: 'Finish the $anchorName pickup first, then this one',
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() => _selectedStopIds = {..._selectedStopIds}..add(orderId));
|
||
}
|
||
|
||
/// True when the ticked set is accepted work — i.e. the next slide is a
|
||
/// pickup rung rather than an accept, and the kitchen bound applies.
|
||
bool get _isPickupSelection {
|
||
if (!ServiceProfile.active.handsOffAtCollection) return false;
|
||
for (final id in _selectedStopIds) {
|
||
final stop = _stopById(id);
|
||
if (stop == null) continue;
|
||
final state = stopStateOf(
|
||
stop,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
);
|
||
if (state == StopState.accepted || state == StopState.active) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Declines exactly the stops the rider ticked, after asking why once.
|
||
///
|
||
/// The mirror of [_acceptSelectedStops], and it works the same way: one
|
||
/// deliberate press on a set he assembled himself, rather than a control on
|
||
/// every row. One reason covers the batch — a rider ticking four stops is
|
||
/// declining them for one cause ("all past my area", "no time left"), and
|
||
/// asking four times would make him pick something, anything, four times.
|
||
Future<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',
|
||
];
|
||
|
||
// Through the kit; content unchanged. (This one also applied a bare
|
||
// `SafeArea` INSIDE an opaque sheet — the scaffold's single inset rule
|
||
// replaces it.)
|
||
return showMilerSheet<String>(
|
||
context,
|
||
builder: (ctx) => MilerSheetScaffold(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
MilerSheetHeader(
|
||
title: count == 1
|
||
? 'Why are you rejecting this stop?'
|
||
: 'Why are you rejecting these $count stops?',
|
||
subtitle: count == 1
|
||
? 'The hub needs a reason to re-route it to someone else.'
|
||
: 'The hub needs a reason to re-route them to someone else.',
|
||
),
|
||
SizedBox(height: 14.h),
|
||
for (final r in reasons) ...[
|
||
MilerButton(
|
||
label: r,
|
||
variant: MilerButtonVariant.outlined,
|
||
color: ColorConstants.slateText,
|
||
height: ButtonSizes.secondary,
|
||
onPressed: () => Navigator.pop(ctx, r),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
],
|
||
SizedBox(height: 4.h),
|
||
MilerButton(
|
||
label: count == 1 ? 'Keep this stop' : 'Keep them',
|
||
variant: MilerButtonVariant.tonal,
|
||
color: ColorConstants.acceptGreen,
|
||
onPressed: () => Navigator.pop(ctx, null),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Returns a rejected stop to undecided.
|
||
///
|
||
/// Not an accept: it goes back to the top of the screen as a tickable stop,
|
||
/// and it is taken the same way as everything else — ticked, then accepted
|
||
/// from the bar. A row control that quietly committed work would be the
|
||
/// per-stop Accept button coming back through the side door.
|
||
Future<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();
|
||
|
||
// ── A declined stop is filed, not deleted ──
|
||
//
|
||
// It used to stay on Home, struck through, with an UNDO beside it, and
|
||
// it was recorded nowhere else — so the only record of a decision the
|
||
// hub will ask about lived on the one screen the rider clears as he
|
||
// works. Now it leaves Home immediately (see `TripCard._showsOnHome`)
|
||
// and lands on Activity with the rest of the day, stamped `rejected` so
|
||
// the row can say **Declined** rather than pretending it was completed.
|
||
await addCompletedBookings([stop], terminalStatus: 'rejected');
|
||
|
||
if (!mounted) return;
|
||
await removeAcceptedBookings([orderId]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_rejectedIds = {..._rejectedIds, orderId};
|
||
_acceptedIds = {..._acceptedIds}..remove(orderId);
|
||
_selectedStopIds = {..._selectedStopIds}..remove(orderId);
|
||
_hiddenOrderIds.add(orderId);
|
||
_ordersMap.remove(orderId);
|
||
_orderIds.remove(orderId);
|
||
_selectedOrders.remove(orderId);
|
||
});
|
||
if (notify) {
|
||
_showStopSnack(message, ColorConstants.secondaryText);
|
||
await _fetchQueues();
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.remove(orderId));
|
||
}
|
||
}
|
||
|
||
/// One-line confirmation, optionally carrying the action that reverses what
|
||
/// it is confirming.
|
||
///
|
||
/// The action is a plain text label rather than anything filled: Material is
|
||
/// explicit that a snackbar action must not out-shout the message it sits
|
||
/// beside, and this one is a safety net, not the main path.
|
||
/// ── A confirmation, not a thing to dismiss ──
|
||
///
|
||
/// This was a Material `SnackBar` at 4 seconds — 8 on an accept, to leave
|
||
/// room for an UNDO. On the rider's phone that reads as a panel that has
|
||
/// *arrived and stayed*: it covers the foot of the route, it sits over the
|
||
/// floating nav bar's neighbourhood, and the only obvious way to be rid of it
|
||
/// is to swipe it down. Confirming six accepted stops is not worth eight
|
||
/// seconds of the screen the rider is trying to keep working on.
|
||
///
|
||
/// `Flushbar` (another_flushbar, pub.dev) at **2 seconds**: it announces, it
|
||
/// leaves, and nothing has to be dismissed. It also animates in and out on
|
||
/// its own clock rather than the Scaffold's, so it is unaffected by the tab
|
||
/// switch an accept triggers.
|
||
///
|
||
/// [duration] is kept as a parameter but defaults to the 2s the rider asked
|
||
/// for; anything that genuinely needs longer has to say so at the call site.
|
||
void _showStopSnack(
|
||
String message,
|
||
Color color, {
|
||
String? actionLabel,
|
||
VoidCallback? onAction,
|
||
Duration duration = const Duration(seconds: 2),
|
||
}) {
|
||
if (!mounted) return;
|
||
|
||
// One at a time. Two accepts in quick succession would otherwise stack.
|
||
_flushbar?.dismiss();
|
||
|
||
final bar = Flushbar<void>(
|
||
messageText: Text(
|
||
message,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
icon: Icon(
|
||
LucideIcons.circleCheck,
|
||
size: 20.sp,
|
||
color: Colors.white.withValues(alpha: 0.95),
|
||
),
|
||
backgroundColor: color,
|
||
duration: duration,
|
||
flushbarPosition: FlushbarPosition.BOTTOM,
|
||
flushbarStyle: FlushbarStyle.FLOATING,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
// Clear of the floating nav bar — the bar is a sibling layer painted over
|
||
// this Scaffold, so a toast measured from the Scaffold's own bottom lands
|
||
// underneath it.
|
||
margin: EdgeInsets.fromLTRB(
|
||
16.w,
|
||
0,
|
||
16.w,
|
||
BottomPage.bottomInset(context) + 8.h,
|
||
),
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
|
||
animationDuration: const Duration(milliseconds: 260),
|
||
forwardAnimationCurve: Curves.easeOutCubic,
|
||
reverseAnimationCurve: Curves.easeInCubic,
|
||
// Two seconds is not long enough to reach for an action, so a toast that
|
||
// carries one is a toast making a promise it cannot keep. Anything
|
||
// reversible is reversed from the tab the work landed on.
|
||
mainButton: (actionLabel == null || onAction == null)
|
||
? null
|
||
: TextButton(
|
||
onPressed: () {
|
||
_flushbar?.dismiss();
|
||
onAction();
|
||
},
|
||
child: Text(
|
||
actionLabel,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
_flushbar = bar;
|
||
bar.show(context);
|
||
}
|
||
|
||
/// The toast currently on screen, so a second one replaces it rather than
|
||
/// stacking behind it.
|
||
Flushbar<void>? _flushbar;
|
||
|
||
/// The stops the last accept actually took, held for the UNDO on its
|
||
/// snackbar. Cleared implicitly — the next accept overwrites it, and undoing
|
||
/// checks each stop is still accepted before touching it.
|
||
List<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,
|
||
);
|
||
// ── An accept the backend refused is not an accept ──
|
||
//
|
||
// This persisted EVERY selected booking regardless of the result,
|
||
// on the reasoning that a flaky network should not cost the rider
|
||
// his selection. What it actually produced was a booking the app
|
||
// believed was accepted and the hub had never heard of — and every
|
||
// rung after it inherits that lie: the row advances to arrived and
|
||
// picked, `pickup-complete` is posted against a booking still
|
||
// sitting in `Miler_Assigned`, no consignment is ever created, and
|
||
// the rider finds out at a customer's door when Delivered is
|
||
// refused because there is nothing to deliver.
|
||
//
|
||
// Accept is the one rung with a cheap retry: the stop is still on
|
||
// Home, still selectable, and pressing again costs a tap. So it
|
||
// records what the backend confirmed and says what it did not —
|
||
// the same rule `_advanceStop` already applies to PICKED.
|
||
if (success) {
|
||
_hiddenOrderIds.add(orderId);
|
||
acceptedBookings.add(Booking);
|
||
} else {
|
||
debugPrint(
|
||
'[BULK] accept REFUSED for $orderId — leaving it on Home',
|
||
);
|
||
}
|
||
} else if (targetStatus == 'ARRIVED') {
|
||
debugPrint('[BULK] Arriving Booking $orderId');
|
||
success = await dc.updateArrivedStatus(
|
||
pickupId: pickupId,
|
||
orderHeaderId: orderHeaderId,
|
||
ridersLat: bulkRidersLat,
|
||
ridersLng: bulkRidersLng,
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
);
|
||
} else if (targetStatus == 'PICKED') {
|
||
debugPrint('[BULK] Picking Booking $orderId');
|
||
success = await dc.updatePickedStatus(
|
||
pickupId: pickupId,
|
||
orderId: orderId,
|
||
orderHeaderId: orderHeaderId,
|
||
pickupLocationId: pickupLocationId,
|
||
ridersLat: bulkRidersLat,
|
||
ridersLng: bulkRidersLng,
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
proofImage: bulkProofImageUrl,
|
||
);
|
||
if (success) {
|
||
_startPickupPosting(Booking);
|
||
_hiddenOrderIds.add(orderId);
|
||
// Picked → remove from the accepted store so it stops counting as
|
||
// an open booking on the Bookings tab, and record it as finished
|
||
// so it lands on Activity. Both stores, or the stop falls out of
|
||
// the app entirely.
|
||
await removeAcceptedBookings([orderId]);
|
||
await addCompletedBookings([Booking]);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[BULK] Error updating Booking $orderId: $e');
|
||
success = false;
|
||
}
|
||
|
||
if (success) {
|
||
successCount++;
|
||
} else {
|
||
debugPrint('[BULK] Failed to update Booking $orderId');
|
||
}
|
||
}
|
||
|
||
// Persist accepted bookings so the Bookings tab can show them and Home
|
||
// keeps them filtered out.
|
||
if (acceptedBookings.isNotEmpty) {
|
||
await addAcceptedBookings(acceptedBookings);
|
||
await stampOrderEvents(
|
||
acceptedBookings.map((b) => (b['orderid'] ?? '').toString()),
|
||
OrderEvent.accepted,
|
||
);
|
||
}
|
||
|
||
_lastAcceptedBatch = List<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;
|
||
});
|
||
bulkSucceeded = acceptedBookings.isNotEmpty || successCount > 0;
|
||
await _fetchQueues();
|
||
|
||
// ── A partial accept has to say so ──
|
||
//
|
||
// The accept path used to skip this message entirely, on the grounds that
|
||
// it navigates away. It now records only what the backend confirmed, so
|
||
// silence would leave the rider looking at stops that did not move with
|
||
// nothing to explain why.
|
||
if (mounted &&
|
||
targetStatus == 'ACCEPTED' &&
|
||
acceptedBookings.length != selectedOrderIds.length) {
|
||
final missed = selectedOrderIds.length - acceptedBookings.length;
|
||
AppFeedback.error(
|
||
context,
|
||
acceptedBookings.isEmpty
|
||
? 'Could not accept — the hub did not confirm it. Try again.'
|
||
: '$missed of ${selectedOrderIds.length} could not be accepted — '
|
||
'they are still on Home.',
|
||
);
|
||
}
|
||
|
||
// For accepts we navigate to the Bookings tab below, so skip the inline
|
||
// snackbar in that case (it would be tied to this soon-to-be-replaced page).
|
||
if (mounted && targetStatus != 'ACCEPTED') {
|
||
final total = selectedOrderIds.length;
|
||
final allOk = successCount == total;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
allOk
|
||
? 'Updated $successCount ${total == 1 ? 'booking' : 'bookings'} successfully'
|
||
: 'Updated $successCount of $total bookings — the rest could not be updated',
|
||
style: const TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
backgroundColor: allOk ? _primaryRed : ColorConstants.warning,
|
||
behavior: SnackBarBehavior.floating,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[BULK] Error: $e');
|
||
} finally {
|
||
if (mounted && Navigator.of(context).canPop()) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
|
||
// ── Accepting takes the rider to the work ──
|
||
//
|
||
// This used to stop at a snackbar reading "work it from the Bookings tab",
|
||
// leaving him on Home watching the stop he had just taken disappear off the
|
||
// screen. From where he was standing the accept had deleted his booking:
|
||
// the row was gone from Home, nothing else on screen had changed, and the
|
||
// tab that did have it was one he had to know to go and open — where, for
|
||
// up to three seconds, the poll had not landed yet either, so it *was*
|
||
// genuinely absent when he arrived.
|
||
//
|
||
// So the accept now does what it says: it moves him, and it takes the data
|
||
// with him. [PickupsController.triggerRefresh] makes Bookings re-fetch on
|
||
// the spot rather than at the next tick of its poller, and the switch is an
|
||
// IndexedStack index change — the same ScaffoldMessenger, so the UNDO
|
||
// snackbar below travels with him and is still there to be pressed.
|
||
if (bulkSucceeded && targetStatus == 'ACCEPTED' && !navigateToBookings) {
|
||
final taken = _lastAcceptedBatch;
|
||
final n = taken.length;
|
||
|
||
try {
|
||
(Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>()
|
||
: Get.put(PickupsController(), permanent: true))
|
||
.triggerRefresh();
|
||
} catch (e) {
|
||
debugPrint('[BULK] Could not poke Bookings to refresh: $e');
|
||
}
|
||
|
||
// ── Only the line that hands off at accept gets moved ──
|
||
//
|
||
// On **logistics** accepting IS the hand-off: the stop leaves Home for
|
||
// the work tab, so following it there is the whole point — see the note
|
||
// above.
|
||
//
|
||
// On a **milk run** it is not. The order stays on Home to be walked up
|
||
// its ladder — accepted, arrived, picked — and only reaches the
|
||
// deliveries tab once it is in the rider's hands. Jumping him there on
|
||
// accept sent him to a tab that was correctly empty and made the accept
|
||
// look like it had filed his order somewhere else. He then had to find
|
||
// his way back to Home to do the actual collecting.
|
||
//
|
||
// So he stays where the work is. The snackbar below still confirms what
|
||
// he took, and the accepted rows are right there under it.
|
||
if (!ServiceProfile.active.handsOffAtCollection) {
|
||
BottomPage.goToTab(1);
|
||
}
|
||
|
||
// ── The undo window ──
|
||
//
|
||
// Accepting is now one deliberate press on a set the rider assembled by
|
||
// ticking, so a mis-tap is rarer than it was with a button on every row —
|
||
// but it is also *bigger*, because one press can take six stops. Eight
|
||
// seconds to take the whole batch back, the same trade Gmail's delete
|
||
// and Material's snackbar guidance make.
|
||
// Two seconds, and no UNDO on it. An eight-second panel held the foot of
|
||
// the screen open purely so an action nobody could reach in time had
|
||
// somewhere to live; the acceptance itself is reversible from the tab the
|
||
// work lands on, which is where the rider is by the time he changes his
|
||
// mind.
|
||
_showStopSnack(
|
||
n == 1 ? 'Stop accepted' : '$n stops accepted',
|
||
ColorConstants.acceptGreen,
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (bulkSucceeded && targetStatus == 'ACCEPTED' && navigateToBookings) {
|
||
AppFeedback.successGlobal(
|
||
'Moved to your ${ServiceProfile.active.workTabLabel}',
|
||
);
|
||
Get.offAll(() => const BottomPage(initialIndex: 1));
|
||
}
|
||
}
|
||
|
||
/// The whole route in one object: load, distance, time windows, shift.
|
||
///
|
||
/// Recomputed each build \u2014 it's a cheap pass over the pending stops, and the
|
||
/// shift countdown has to stay honest as the day burns down.
|
||
RouteBrief get _routeBrief => RouteBrief.from(
|
||
orderIds: _orderIds,
|
||
ordersMap: _ordersMap,
|
||
shiftStart: _shiftStart,
|
||
shiftEnd: _shiftEnd,
|
||
extraStops: _acceptedStops,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// One configured [TripCard] for the selected trip.
|
||
///
|
||
/// Home does not render the card as a single widget any more: its summary, its
|
||
/// stop list and its action bar go into the sliver list separately so the
|
||
/// Trip 1 / 2 / 3 bar can be *pinned* between the summary and the stops. This
|
||
/// holds the configuration \u2014 every callback and every id set \u2014 in one place, so
|
||
/// the three sections cannot drift apart. See the note above the section
|
||
/// getters in `trip_card.dart`.
|
||
TripCard _tripCardFor(Trip trip) => TripCard(
|
||
key: ValueKey('trip_${trip.id}'),
|
||
trip: trip,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
// Shift window, pace and cash — the summary strip shows the shift and
|
||
// the trip's own slot as two labelled rows, which is the whole reason
|
||
// the page has to hand this down.
|
||
brief: _routeBrief,
|
||
busyStopIds: _busyStopIds,
|
||
collectedIds: _collectedIds,
|
||
// How far the next pickup is. The same fix the detail sheet and the map
|
||
// already use — a live geocoded position, falling back to the last one
|
||
// stored — so the distance on the route header and the distance inside the
|
||
// stop cannot disagree.
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
// ── The card opens a preview, and the preview hands off ──
|
||
//
|
||
// `Navigate to pickup` used to go straight out to Google Maps. That is one
|
||
// tap too few: handing off means the rider *leaves Miler*, and he was
|
||
// making that commitment on the strength of a kitchen's name and a
|
||
// straight-line distance. The preview draws the leg from where he is
|
||
// standing to the counter and lists what he will collect there; its own
|
||
// primary button is the real hand-off. Built here rather than inside
|
||
// [TripCard] because that class's three build methods are deliberately
|
||
// context-free — Home calls them directly as slivers.
|
||
onPreviewGroup: (g) => PickupPreviewSheet.show(
|
||
context,
|
||
placeName: g.name,
|
||
lines: g.manifest,
|
||
meters: g.meters,
|
||
ride: g.rideTime,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
onNavigate:
|
||
g.stops.isEmpty || MilkRun.navigationTarget(g.stops.first) == null
|
||
? null
|
||
: () {
|
||
Navigator.of(context).pop();
|
||
_navigateToStop(g.stops.first);
|
||
},
|
||
onCall: g.stops.isEmpty || stopPhoneOf(g.stops.first).isEmpty
|
||
? null
|
||
: () {
|
||
Navigator.of(context).pop();
|
||
_callStop(g.stops.first);
|
||
},
|
||
),
|
||
// Only a tenant that collects from a source gets the control; on a parcel
|
||
// route there is nothing to collect from and the header never renders.
|
||
selectedIds: _selectedStopIds,
|
||
onToggleSelect: _toggleStopSelection,
|
||
onSelectAll: (ids) => setState(() => _selectedStopIds = ids.toSet()),
|
||
onAcceptSelected: _acceptSelectedStops,
|
||
// The way back for a stop he declined. It does not accept anything by
|
||
// itself — see the note at the row. (Resuming a *skipped* stop is
|
||
// Activity's control now: a skip is a record of what happened at a
|
||
// door, not an offer waiting on a decision.)
|
||
onUnrejectStop: _unrejectStop,
|
||
// No per-stop or whole-trip accept any more: ticking chooses, the
|
||
// floating bar commits. See the decision-row note in `trip_card.dart`.
|
||
// Map + full address + everything the card truncates.
|
||
onStopTap: (stop) => StopDetailSheet.show(
|
||
context,
|
||
stop: stop,
|
||
stopNumber: trip.stops.indexOf(stop) + 1,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
),
|
||
onCallStop: (stop) => _callStop(stop),
|
||
// Setting off, straight from the row. Where it points follows the stop's
|
||
// stage: the kitchen while it is still to be collected, the customer once
|
||
// it is in his hands.
|
||
onNavigateStop: _navigateToStop,
|
||
// The single-stop ladder: a slide sheet per card, alongside the floating
|
||
// bar's batch version.
|
||
onAdvanceStop: _advanceStopWithSheet,
|
||
// Back into the stop he is already on. This is what the red "Active
|
||
// Pickup" strip at the foot of the page used to be — except that one
|
||
// only refetched the queue and never actually opened anything.
|
||
onContinueStop: _continueStop,
|
||
// The whole route on one map. Reads the trip's own ordering into the
|
||
// step numbers so the pins are labelled the way the card lists them,
|
||
// rather than however the map screen would number them itself.
|
||
onViewRoute: trip.stops.isEmpty
|
||
? null
|
||
: () => openScreen(
|
||
context,
|
||
pickups.MultiCustomerMapScreen(
|
||
pickup: trip.stops,
|
||
preservedStepNumbers: {
|
||
for (var i = 0; i < trip.stops.length; i++)
|
||
(trip.stops[i]['orderid'] ?? '').toString(): i + 1,
|
||
},
|
||
),
|
||
swipeToGoBack: false,
|
||
),
|
||
);
|
||
|
||
/// \u2500\u2500 The app bar \u2014 pinned, never scrolls away \u2500\u2500
|
||
///
|
||
/// This used to be a `SliverToBoxAdapter` at the top of the scroll view, so
|
||
/// the rider's identity *and the ON/OFF duty toggle* slid off the screen as
|
||
/// soon as he looked at his second stop. The toggle is the control that
|
||
/// governs everything else on the page \u2014 going off duty stops work being
|
||
/// assigned \u2014 so needing to scroll back up to reach it was the wrong trade.
|
||
/// It is now a sibling above the scroll view, which pins it outright.
|
||
///
|
||
/// Only the identity row is pinned. The shift window that used to share this
|
||
/// white block scrolls with the content now (see [_buildShiftRow]): it is
|
||
/// read-once reference information, and holding it up here would spend ~44
|
||
/// permanent pixels \u2014 on top of the status bar, this bar, the trip tabs and
|
||
/// the bottom nav \u2014 to keep a static time range on screen.
|
||
Widget _buildAppBar() {
|
||
final String firstName = _userName.isNotEmpty
|
||
? _userName.split(' ').first
|
||
: "Rider";
|
||
|
||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||
// ── The status bar belongs to the header ──
|
||
//
|
||
// On a brand ground the clock and the battery have to invert with it, or
|
||
// the top 44pt of the phone is dark glyphs on maroon — which is the one
|
||
// contrast failure a rider cannot work around by tilting the screen.
|
||
value: const SystemUiOverlayStyle(
|
||
statusBarColor: Colors.transparent,
|
||
statusBarIconBrightness: Brightness.light,
|
||
statusBarBrightness: Brightness.dark,
|
||
),
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
// ── A branded header, not a coloured rectangle ──
|
||
//
|
||
// It wore the same 7% frosted wash as the other three tabs, flattened
|
||
// against white. That is right for a tab you pass through; Home is
|
||
// the screen the rider *lives* on, and it is the one place the app
|
||
// gets to say whose app it is without spending a logo on it.
|
||
//
|
||
// Solid brand, and everything on it inverts: the greeting, the duty
|
||
// word, the switch, the status-bar glyphs. Nothing is tinted — white
|
||
// on `#960019` clears AA at every size used here, and a "mostly
|
||
// white" header text would be the one thing on the bar that looks
|
||
// like a mistake.
|
||
color: ColorConstants.primary,
|
||
),
|
||
child: SafeArea(
|
||
// Inset handled explicitly below via MediaQuery so the status bar
|
||
// never overlaps the header, regardless of nested-Scaffold quirks.
|
||
top: false,
|
||
bottom: false,
|
||
child: Padding(
|
||
// Top gap went 8 → 18 → 28. At 8 the greeting sat almost against
|
||
// the status bar, which was tolerable while the header scrolled
|
||
// away but not now that it is pinned there all day — a fixed bar
|
||
// with no air above it reads as clipped rather than as a bar. 18
|
||
// was still tight against a taller left-hand avatar, and it has to
|
||
// match the inset [MilerAppBar] uses on the other three tabs or the
|
||
// headings jump vertically as the rider switches tab.
|
||
//
|
||
// Bottom inset is the bar's own breathing room rather than a gap
|
||
// before the shift row, so it stays tight at 12.
|
||
// 28 → 20 above, 12 → 16 below. On white the extra top inset was
|
||
// stopping the greeting reading as clipped against the status bar;
|
||
// on a solid ground the bar *is* the status bar's surface, so the
|
||
// gap above can close and the type sits in a balanced band instead
|
||
// of being pushed to its floor.
|
||
padding: EdgeInsets.fromLTRB(
|
||
20.w,
|
||
MediaQuery.of(context).padding.top + 20.h,
|
||
16.w,
|
||
16.h,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// ── Identity row ──
|
||
//
|
||
// The slot at the left used to hold the Doormile mark, on every
|
||
// tab. The logo is gone from all of them now — the rider knows
|
||
// which app he opened — and Home is the one screen that has
|
||
// something better to put there: him. [MilerAvatar] draws his
|
||
// profile photo, or the first letter of his name when he has
|
||
// not set one, at exactly the size the mark occupied so the row
|
||
// keeps its proportions.
|
||
//
|
||
// It also gives the greeting a subject: face, then "Hi, Suriya",
|
||
// then the duty toggle that governs his shift — one row that is
|
||
// entirely about the person using the app.
|
||
Row(
|
||
children: [
|
||
MilerAvatar(name: _userName, onBrand: true),
|
||
SizedBox(width: 12.w),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── An eyebrow the greeting can lead ──
|
||
//
|
||
// "Hi, Suriya" alone was one line floating in a band
|
||
// with a switch. Naming the line of work above it
|
||
// gives the header a subject and a predicate — this
|
||
// is a *shift*, and here is whose — and it is the one
|
||
// place the tenant's operation is stated at all.
|
||
Text(
|
||
ServiceProfile.active.label.toUpperCase(),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: MilerType.eyebrow.copyWith(
|
||
color: Colors.white.withValues(alpha: 0.72),
|
||
),
|
||
),
|
||
SizedBox(height: 3.h),
|
||
// Greeting + name is the anchor of the screen, so it
|
||
// gets the heaviest type in the header.
|
||
Text(
|
||
"Hi, $firstName",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 22.sp,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.1,
|
||
letterSpacing: -0.5,
|
||
color: ColorConstants.onAccent,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(width: 12.w),
|
||
_buildStatusToggle(),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// ── Duty control ──
|
||
///
|
||
/// A switch, because that is what a boolean the rider flips twice a day is.
|
||
///
|
||
/// It has been three things. First a skeuomorphic switch — gradient thumb,
|
||
/// two drop shadows, a glowing pulse dot — whose thumb slid LEFT for on and
|
||
/// RIGHT for off, backwards from every switch ever made. Then a single
|
||
/// button, which showed one word and left the rider guessing whether it was
|
||
/// his state or the thing tapping would do. Then a two-segment ON | OFF
|
||
/// control, which answered that but reads as a form input: two boxes of equal
|
||
/// weight, neither of which looks like the live thing it controls.
|
||
///
|
||
/// This is the Material 3 switch — a 52×32 track whose fill IS the state, a
|
||
/// handle that grows and carries a tick when it lands on, and a word beside
|
||
/// it so nothing depends on colour alone. It travels left→right into "on" the
|
||
/// way every switch the rider owns does, and the whole row is the target, not
|
||
/// just the 52pt track.
|
||
///
|
||
/// **Locked** while a pickup is live: going off duty mid-stop would strand a
|
||
/// customer. The handle takes a padlock and the row dims rather than the
|
||
/// control vanishing, so the state stays readable.
|
||
Widget _buildStatusToggle() {
|
||
final bool locked = _hasActivePickup();
|
||
final bool on = isOnline;
|
||
|
||
// ── Both states on screen, all the time ──
|
||
//
|
||
// This was a hand-rolled M3 switch: a track, a travelling handle, and the
|
||
// word for the CURRENT state beside it. Two problems a rider has on a bike.
|
||
// A bare switch is a *guess* — "is this the on position or the off one?" —
|
||
// answered only by remembering which side means what; and the word changed
|
||
// as he pressed it, so the label he was reading and the control he was
|
||
// aiming at both moved.
|
||
//
|
||
// `AnimatedToggleSwitch.dual` (pub.dev, ~2k likes, actively maintained) is
|
||
// the shape this control always wanted: a pill holding the state as a word
|
||
// AND a knob whose position says the same thing a second time, sliding
|
||
// between them. Nothing is inferred from a position alone, and the package
|
||
// brings the rolling indicator, the touch-target floor and the disabled
|
||
// state with it rather than this file growing its own.
|
||
//
|
||
// On the brand ground it stays monochrome. Green-on-maroon is the one
|
||
// pairing this palette cannot make: the two are close in luminance, so a
|
||
// green "on" read as a smudge. **On** is a solid white knob carrying a
|
||
// brand tick — the strongest mark available here, and the live state is
|
||
// correctly the filled one. **Off** is a hollow white knob on a translucent
|
||
// track: unlit, not absent. Neither depends on hue.
|
||
return Semantics(
|
||
toggled: on,
|
||
enabled: !locked,
|
||
label: locked
|
||
? 'On duty, locked while a pickup is running'
|
||
: on
|
||
? 'On duty. Switch off to stop receiving trips.'
|
||
: 'Off duty. Switch on to receive trips.',
|
||
excludeSemantics: true,
|
||
child: AnimatedToggleSwitch<bool>.dual(
|
||
current: on,
|
||
first: false,
|
||
second: true,
|
||
// Locked mid-pickup: going off duty would strand a customer. The
|
||
// package dims rather than removing it, so the state stays readable.
|
||
active: !locked,
|
||
inactiveOpacity: 0.55,
|
||
height: 34.h,
|
||
spacing: 6.w,
|
||
borderWidth: 1.5,
|
||
indicatorSize: Size.fromWidth(30.w),
|
||
animationDuration: const Duration(milliseconds: 260),
|
||
animationCurve: Curves.easeOutCubic,
|
||
style: ToggleStyle(
|
||
backgroundColor: on
|
||
? Colors.white.withValues(alpha: 0.18)
|
||
: Colors.white.withValues(alpha: 0.12),
|
||
borderColor: Colors.white.withValues(alpha: on ? 0.85 : 0.40),
|
||
indicatorColor: Colors.white,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||
indicatorBorderRadius: BorderRadius.circular(
|
||
DesignConstants.radiusFull,
|
||
),
|
||
),
|
||
iconBuilder: (value) => Icon(
|
||
locked
|
||
? LucideIcons.lock
|
||
: value
|
||
? LucideIcons.check
|
||
: LucideIcons.pause,
|
||
size: 15.sp,
|
||
color: ColorConstants.primary,
|
||
),
|
||
textBuilder: (value) => Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 2.w),
|
||
child: Text(
|
||
value ? 'On duty' : 'Off duty',
|
||
maxLines: 1,
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: -0.2,
|
||
color: Colors.white,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
// The confirmation sheet owns the decision — see
|
||
// [_confirmOnlineOffline]. The switch never changes duty by itself.
|
||
onChanged: locked ? null : (_) => _confirmOnlineOffline(),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Shown on a trip tab the hub has not filled yet.
|
||
///
|
||
/// The whole thing lives in [TripEmptyState] — including the reasoning for
|
||
/// why it is no longer a bordered card. Keyed on the tab so switching from
|
||
/// one unassigned slot to another restarts the entrance rather than silently
|
||
/// swapping the heading.
|
||
Widget _buildTripEmptyState(int index) {
|
||
return TripEmptyState(
|
||
key: ValueKey('trip-empty-$index'),
|
||
tripLabel: Trip.tabLabel(index),
|
||
offline: !isOnline,
|
||
onGoOnDuty: _confirmOnlineOffline,
|
||
);
|
||
}
|
||
|
||
Widget _buildEmptyState() {
|
||
// Offline riders don't receive bookings, so the empty state must explain
|
||
// *why* it's empty and offer the one action that fixes it — going online.
|
||
final bool offline = !isOnline;
|
||
|
||
// ── Online, nothing assigned: the art illustrates, the type speaks ──
|
||
//
|
||
// The artwork ships with its own headline baked into the pixels — "No
|
||
// Bookings **Available**" with a red accent word, and a marketing line
|
||
// ending in an exclamation mark. Baked type cannot be Poppins, cannot
|
||
// scale with the rider's system text size, and reads as a poster pasted
|
||
// into the app rather than the app talking. So the image is cropped to
|
||
// its illustration — the bottom band where the words live is cut — and
|
||
// the message is set below it in the design system's own voice, exactly
|
||
// as every other empty state on the app does.
|
||
if (!offline) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 28.w),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── The artwork carries its own white, so give it an edge ──
|
||
//
|
||
// Seen on a device: this asset has a white background baked in,
|
||
// and Home's ground is the canvas — so a hard white rectangle
|
||
// sat on grey with visible corners, reading as a failed image
|
||
// rather than an illustration.
|
||
//
|
||
// Rounding it turns the asset's own ground into a deliberate
|
||
// layer-1 surface. Nothing is masked or recoloured; the picture
|
||
// simply stops having square corners on a page that has none.
|
||
SizedBox(
|
||
width: 280.w,
|
||
child: AspectRatio(
|
||
// The illustration occupies the top ~75% of the 3:2 asset;
|
||
// everything below that line is the baked copy being
|
||
// removed. Top-aligned cover crop keeps scooter, rider and
|
||
// pin whole.
|
||
aspectRatio: 1536 / 770,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(
|
||
DesignConstants.radiusXl,
|
||
),
|
||
child: Image.asset(
|
||
'assets/images/no_bookings_available.png',
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.topCenter,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 18.h),
|
||
Text(
|
||
'No bookings yet',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: -0.4,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 6.h),
|
||
Text(
|
||
'Stay on duty — new orders appear here the moment '
|
||
'the hub assigns them.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 13.5.sp,
|
||
height: 1.45,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
// Offline Lottie in its native colors.
|
||
SizedBox(
|
||
width: 210.w,
|
||
height: 180.w,
|
||
child: Lottie.asset(
|
||
'assets/lotties/no_internet.json',
|
||
fit: BoxFit.contain,
|
||
repeat: true,
|
||
),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
Text(
|
||
"You're Offline",
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textPrimary,
|
||
),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
Text(
|
||
"Go online to start receiving bookings",
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
SizedBox(height: 18.h),
|
||
ElevatedButton.icon(
|
||
onPressed: _hasActivePickup() ? null : _confirmOnlineOffline,
|
||
icon: Icon(LucideIcons.zap, size: 18.sp),
|
||
label: Text(
|
||
"Go Online",
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _primaryRed,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
// Mid-size: content-hugging but with a comfortable min width,
|
||
// so it's neither full-width nor a tiny chip. The height is
|
||
// the unscaled [ButtonSizes.compact] rather than `44.h` —
|
||
// ScreenUtil would shrink a scaled 44 below the tap-target
|
||
// floor on a short phone, which is where it matters most.
|
||
minimumSize: Size(150.w, ButtonSizes.compact),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 11.h),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
// ── `_buildBookingCard` is gone ──
|
||
//
|
||
// A ~600-line alternate Home, unreachable: no call site anywhere in the app.
|
||
// It drew a stop as a bordered, shadowed, rounded card with its own status
|
||
// pill, its own accept/reject pair and its own address treatment — a second
|
||
// and contradictory visual system for the same object the route node now
|
||
// draws, sitting in the production file waiting for somebody to wire it back
|
||
// up because it looked finished.
|
||
//
|
||
// Nothing was salvaged from it. Every helper it called is still used by the
|
||
// live composition; it called no logic of its own that the route timeline
|
||
// does not already own.
|
||
|
||
Widget _buildActionButton({
|
||
required IconData icon,
|
||
required Color color,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 38.w,
|
||
height: 38.w,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
child: Icon(icon, color: color, size: 18.sp),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBulkActionButton() {
|
||
final selectedCount = _selectedOrders.values.where((s) => s).length;
|
||
if (selectedCount <= 1) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
color: _primaryRed,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: _primaryRed.withValues(alpha: 0.25),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 6),
|
||
),
|
||
],
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: _hasActivePickup() ? null : _handleBulkAction,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(LucideIcons.zap, color: Colors.white, size: 20.sp),
|
||
SizedBox(width: 8.w),
|
||
Text(
|
||
'${_getBulkButtonLabel()} (${selectedCount})',
|
||
style: TextStyle(
|
||
fontSize: 15.sp,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: Colors.white,
|
||
letterSpacing: 0.3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==================== MAIN BUILD METHOD ====================
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
// The day, grouped into the slots the hub assigned — see [_trips]. The tab
|
||
// bar below chooses one of them; [trip] is null when that slot is still
|
||
// empty, which is its own screen and not "no work".
|
||
final List<Trip> trips = _trips;
|
||
final Trip? trip = _selectedTrip;
|
||
final bool hasWork = trips.isNotEmpty;
|
||
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, result) async {
|
||
if (didPop) return;
|
||
bool shouldExit = true;
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final hasLive = prefs.getBool('has_live_pickup') ?? false;
|
||
if (hasLive) {
|
||
final confirm = await showAppDialog<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.w600,
|
||
),
|
||
),
|
||
content: Text(
|
||
'Are you sure you want to close? There are pickups pending.',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(
|
||
'No',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(
|
||
'Yes',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _primaryRed,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
shouldExit = confirm == true;
|
||
}
|
||
} catch (_) {}
|
||
if (shouldExit) {
|
||
// Home is the root tab — leaving it backgrounds the app.
|
||
await SystemNavigator.pop();
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: _surfaceColor,
|
||
body: Stack(
|
||
children: [
|
||
// ── Pinned bar, scrolling route ──
|
||
//
|
||
// A Column, not a single scroll view: the app bar is the Column's
|
||
// first child and the scrollable takes the rest, which is what fixes
|
||
// the bar in place. It was previously the first sliver *inside* the
|
||
// scroll view, so it scrolled away with everything else — taking the
|
||
// duty toggle with it.
|
||
Column(
|
||
children: [
|
||
_buildAppBar(),
|
||
|
||
// ── Pull to refresh ──
|
||
//
|
||
// Home was the only tab without this. Bookings, Earnings, Cart and
|
||
// Notifications all had a RefreshIndicator; here a pull just
|
||
// dragged the whole page — header included — down off the top of
|
||
// the screen, held it against a blank surface-coloured gap, and
|
||
// sprang back having done nothing. That is not a missing feature
|
||
// so much as a broken-looking one: the gesture responded, so it
|
||
// read as a refresh that had failed.
|
||
//
|
||
// Physics is half the fix. `BouncingScrollPhysics` forced the iOS
|
||
// rubber-band on every platform, and rubber-band overscroll is
|
||
// unbounded — hence the gap in the screenshot. Passing plain
|
||
// `AlwaysScrollableScrollPhysics` lets `applyTo` compose it with
|
||
// the platform default instead (clamping on Android, bouncing on
|
||
// iOS), so the overscroll is bounded where it should be, while
|
||
// "always scrollable" keeps the pull working on a short list.
|
||
//
|
||
// `edgeOffset` is gone with the bar: the scroll view now starts
|
||
// below it, so the spinner already comes down into clear space
|
||
// instead of out from behind the status bar.
|
||
Expanded(
|
||
// ── The corner has to have something behind it ──
|
||
//
|
||
// The sheet was clipping straight onto the Scaffold, whose
|
||
// background is this same `_surfaceColor` — so the two
|
||
// rounded corners revealed the page's own colour and the
|
||
// curve was invisible. Home read as a hard maroon line above
|
||
// a form while the other three tabs had the shape.
|
||
//
|
||
// The other three get theirs from `backgroundColor:
|
||
// primary` on the Scaffold; Home cannot, because its Stack
|
||
// paints other things on that ground. So the brand is laid
|
||
// directly behind the sheet instead — the same result, scoped
|
||
// to the strip that needs it.
|
||
child: ColoredBox(
|
||
color: ColorConstants.primary,
|
||
child: MilerSheet(
|
||
color: _surfaceColor,
|
||
child: RefreshIndicator(
|
||
onRefresh: _handleRefresh,
|
||
color: ColorConstants.primary,
|
||
child: CustomScrollView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
slivers: [
|
||
// ══════════════════════════════════════════════════════════
|
||
// READING ORDER
|
||
//
|
||
// [app bar] fixed
|
||
// shift window scrolls away
|
||
// trip summary scrolls away (ring, slot, load, metrics)
|
||
// ─ TRIP 1 · 2 · 3 ─ PINNED
|
||
// hint line scrolls
|
||
// HUB → stops → HUB scrolls
|
||
// accept bar scrolls
|
||
//
|
||
// The tab bar is the only thing between the summary and the stops,
|
||
// and it is the only pinned sliver. Everything above it — the
|
||
// shift window, the completion ring, the duration and distance —
|
||
// is reference material the rider reads once and then works from
|
||
// memory, so it is allowed to leave. The tabs are a control that
|
||
// governs the whole screen, so they stay. See [TripTabsHeader].
|
||
// ══════════════════════════════════════════════════════════
|
||
|
||
// The trip brief used to sit here, above the tabs. It now sits
|
||
// below them, inside the per-trip block — see the note at its new
|
||
// call site.
|
||
//
|
||
// The standalone shift-window strip that used to sit above it is
|
||
// gone too: it showed the rider's shift while the card below it
|
||
// showed the *trip's* slot under the caption "CURRENT SHIFT", so
|
||
// two different time ranges sat stacked with one wearing the
|
||
// other's name. Both now live inside [TripBriefStrip] as adjacent
|
||
// labelled rows. See that class for the rest of the reasoning.
|
||
|
||
// ── Trip 1 · 2 · 3, pinned ──
|
||
//
|
||
// Only shown once the day has at least one trip in it: with
|
||
// nothing assigned the bar would be three empty controls above a
|
||
// "you have no bookings" panel, which reads as a broken screen
|
||
// rather than an idle one.
|
||
if (hasWork)
|
||
SliverPersistentHeader(
|
||
pinned: true,
|
||
delegate: TripTabsHeader(
|
||
trips: trips,
|
||
selectedIndex: _selectedTripIndex,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
extent: TripTabsHeader.extentFor(context),
|
||
// Selection is per-trip, so the ticks do not travel
|
||
// with the rider between tabs — a tick made on Trip 1
|
||
// must not be counted by the bar while Trip 2 is on
|
||
// screen. See [_selectedPendingIds].
|
||
onSelect: (index) {
|
||
if (index == _selectedTripIndex) return;
|
||
setState(() {
|
||
_selectedTripIndex = index;
|
||
_selectedStopIds.clear();
|
||
});
|
||
},
|
||
),
|
||
),
|
||
|
||
// The stops, an empty state for an unassigned slot, or — until
|
||
// the first fetch lands — the shape of a route being loaded.
|
||
//
|
||
// One swap holding all of them, so the hand-off between them is
|
||
// a cross-fade instead of one sliver being swapped for another
|
||
// and the screen going briefly blank in between.
|
||
//
|
||
// ── Why the sliver type changes with the state ──
|
||
//
|
||
// With nothing to list, the message *is* the page, and it
|
||
// belongs in the middle of it. A box adapter shrink-wraps its
|
||
// child, so a Center inside one only centres within the
|
||
// artwork's own height — which parked it under the app bar at
|
||
// the top of an otherwise blank screen. [SliverFillRemaining]
|
||
// hands it the rest of the viewport to be centred in, and
|
||
// `hasScrollBody: false` still lets it grow past that on a
|
||
// short phone rather than clipping.
|
||
//
|
||
// A real route keeps the adapter: filling the remainder there
|
||
// would pad every short trip with a screen of dead space above
|
||
// the floating-pill floor.
|
||
if (!hasWork)
|
||
SliverFillRemaining(
|
||
hasScrollBody: false,
|
||
child: SmoothSwap(
|
||
child: !_queuesLoaded
|
||
// The real timeline in grey, not three
|
||
// generic cards — see [HomeRouteSkeleton].
|
||
? const HomeRouteSkeleton(
|
||
key: ValueKey('home-loading'),
|
||
)
|
||
: _fetchFailed
|
||
? Padding(
|
||
key: const ValueKey('home-error'),
|
||
padding: EdgeInsets.symmetric(
|
||
vertical: 32.h,
|
||
),
|
||
child: ErrorRetry(
|
||
onRetry: _handleRefresh,
|
||
),
|
||
)
|
||
// ── A day with no work says so ──
|
||
//
|
||
// This rendered `SizedBox.shrink()` —
|
||
// literally nothing — so a rider whose hub
|
||
// had not assigned him anything opened Home
|
||
// to a blank screen and could not tell it
|
||
// apart from a screen that had failed to
|
||
// load. The empty state already existed and
|
||
// had simply been orphaned when the trip tabs
|
||
// came out; it states which of the two this
|
||
// is, and offers the one action that changes
|
||
// it when he is off duty.
|
||
: KeyedSubtree(
|
||
key: const ValueKey('home-empty'),
|
||
child: _buildEmptyState(),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
SliverToBoxAdapter(
|
||
child: SmoothSwap(
|
||
// ── A slot the hub has not filled ──
|
||
//
|
||
// The day has work, just not on this tab. Says so
|
||
// by name rather than reusing the whole-day empty
|
||
// state, which would claim he has nothing to do
|
||
// while another trip is running.
|
||
child: trip == null
|
||
? KeyedSubtree(
|
||
key: ValueKey(
|
||
'trip-empty-$_selectedTripIndex',
|
||
),
|
||
child: _buildTripEmptyState(
|
||
_selectedTripIndex,
|
||
),
|
||
)
|
||
: Reveal(
|
||
// Keyed on the trip, so switching tabs plays
|
||
// the entrance once and the 5s poll never
|
||
// replays it.
|
||
key: ValueKey('reveal_${trip.id}'),
|
||
child: Column(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
// The brief sits UNDER the tabs, not above.
|
||
//
|
||
// It describes one trip, and the tabs are what
|
||
// choose that trip — so above them it was
|
||
// answering a question the rider had not been
|
||
// asked yet, and switching tabs changed a
|
||
// panel he had already scrolled past. Below
|
||
// them it reads as the selected tab's own
|
||
// content, which is what it is, and it lands
|
||
// inside the per-trip [Reveal] so it swaps
|
||
// with the route it belongs to.
|
||
_tripCardFor(trip).buildSummary(),
|
||
// The "one thing to do now" head that
|
||
// used to sit here is gone. It drew the
|
||
// current place, its load and a
|
||
// full-width **Navigate to pickup** —
|
||
// all of which the timeline's own group
|
||
// node below already carries, button
|
||
// included. Two identical red buttons
|
||
// for one destination was the single
|
||
// largest thing on this screen; the
|
||
// node that owns the journey owns the
|
||
// action.
|
||
_tripCardFor(trip).buildStops(),
|
||
_tripCardFor(
|
||
trip,
|
||
).buildActionBar(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── Room for everything that floats over this list ──
|
||
//
|
||
// 80 was measured against the accepted pill alone. Two
|
||
// more things pin to the same corner — the selection
|
||
// bar and the live-pickup banner — and with a banner up
|
||
// the last stop card was covered by it with no way to
|
||
// scroll clear, which is what a rider reported as "the
|
||
// card is cut off".
|
||
//
|
||
// The floor now clears the nav bar plus the tallest
|
||
// stack that can sit on it, so the end of the route is
|
||
// always reachable.
|
||
// ── Measured, not assumed ──
|
||
//
|
||
// This was a flat `80.h`. The two surfaces that can
|
||
// occupy the bottom slot now declare their own
|
||
// heights, and the floor reserves the taller of the
|
||
// two plus the device inset — so the last route row
|
||
// clears whichever one is showing, at any text
|
||
// scale, without anybody re-measuring by eye.
|
||
SliverToBoxAdapter(
|
||
child: SizedBox(
|
||
height:
|
||
(SelectionBar.maxHeight >
|
||
AcceptedPill.maxHeight
|
||
? SelectionBar.maxHeight
|
||
: AcceptedPill.maxHeight) +
|
||
BottomPage.bottomInset(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
// ── The bottom overlay ──
|
||
//
|
||
// Only one thing floats over the foot of Home now. The red "Active
|
||
// Pickup" strip that used to share this column is gone: it was a
|
||
// second object describing a stop the list below was hiding, it
|
||
// covered the last card, and its tap did nothing but refetch. The
|
||
// live stop is a row in the trip now, marked LIVE and carrying its
|
||
// own way back in — see `TripCard._showsOnHome`.
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
// ── The command surface arrives, it does not appear ──
|
||
//
|
||
// Ticking a row swaps one floating object for another in the same
|
||
// slot, and swapping them in a single frame reads as the page
|
||
// flickering rather than as a mode changing. A short rise-and-fade
|
||
// — the app's own state duration, no bounce — is what says "this
|
||
// came up because of what you just did".
|
||
child: AnimatedSwitcher(
|
||
duration: DesignConstants.motionState,
|
||
switchInCurve: Curves.easeOutCubic,
|
||
switchOutCurve: Curves.easeInCubic,
|
||
transitionBuilder: (child, anim) => FadeTransition(
|
||
opacity: anim,
|
||
child: SlideTransition(
|
||
position: Tween<Offset>(
|
||
begin: const Offset(0, 0.35),
|
||
end: Offset.zero,
|
||
).animate(anim),
|
||
child: child,
|
||
),
|
||
),
|
||
child: Column(
|
||
key: ValueKey(_selectionCount > 0),
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_selectionCount > 0)
|
||
Builder(
|
||
builder: (context) {
|
||
final action = _selectionAction();
|
||
return SelectionBar(
|
||
count: _selectionCount,
|
||
action: action,
|
||
busy:
|
||
_acceptingTripId == 'selection' ||
|
||
_busyStopIds.isNotEmpty,
|
||
onClear: () =>
|
||
setState(() => _selectedStopIds = <String>{}),
|
||
onAccept: _acceptingTripId != null
|
||
? null
|
||
: () => _acceptSelectedStops(
|
||
_selectedPendingIds().toList(),
|
||
),
|
||
// Arrived / picked up run against the whole ticked
|
||
// set in one slide — see [_advanceSelection].
|
||
onAdvance: _advanceSelection,
|
||
// A stop already taken on cannot be declined from
|
||
// here, so Reject leaves with the accept rung.
|
||
onReject:
|
||
action != SelectionAction.accept ||
|
||
_acceptingTripId != null
|
||
? null
|
||
: () => _rejectSelectedStops(
|
||
_selectedPendingIds().toList(),
|
||
),
|
||
);
|
||
},
|
||
)
|
||
// ── The hinge is not here ──
|
||
//
|
||
// START DELIVERY used to sit in this slot, and it was in the
|
||
// wrong place. Home is the *collection* workspace: a stop
|
||
// leaves it the moment it is in the rider's hands. So the
|
||
// control that starts the round lived on a screen the round's
|
||
// orders had already left — and the count it showed was the
|
||
// only trace of them anywhere in the app.
|
||
//
|
||
// It belongs where the load is, which is the deliveries tab.
|
||
// See the bar at the foot of `pickups.dart`.
|
||
else
|
||
// Only when nothing is ticked. The two share this slot on
|
||
// purpose — see [AcceptedPill] — and a live selection is
|
||
// the more urgent of the two.
|
||
// ── No SafeArea here ──
|
||
//
|
||
// [BottomPage.bottomInset] already includes
|
||
// `MediaQuery.padding.bottom`, so wrapping the pill in a
|
||
// `SafeArea` added the gesture bar's inset a second time
|
||
// and floated it ~34pt clear of the nav on the phones
|
||
// that have one. The inset is owned in one place; a
|
||
// second opinion about it is what makes a floating
|
||
// control drift between devices.
|
||
AcceptedPill(
|
||
// ── Derived, not counted twice ──
|
||
//
|
||
// This read `_acceptedStops.length` — the length of a
|
||
// persisted list of accepted bookings. That store is
|
||
// written on accept and cleaned on completion, so any
|
||
// stop that left the day some other way (the hub
|
||
// pulling it, a reject that raced a refetch, a run
|
||
// that simply ended) stayed in it, and the pill
|
||
// counted work the rider no longer had. It is a
|
||
// second source of truth for a number the trip
|
||
// already knows.
|
||
//
|
||
// The trip is that source: today's stops, resolved
|
||
// against the same accepted/rejected sets every other
|
||
// surface on this screen reads. Accepting two shows
|
||
// two; rejecting one drops it in the same frame,
|
||
// because the set it is derived from changed.
|
||
count: _acceptedWaitingCount,
|
||
// Moves the shell's IndexedStack rather than
|
||
// rebuilding it, so Bookings keeps its state and the
|
||
// jump lands.
|
||
onTap: () => BottomPage.goToTab(1),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// The status-bar scrim that used to sit here is gone. It was a white
|
||
// strip pinned over the status bar, and it existed for exactly one
|
||
// reason: the header scrolled, so content slid up under the clock and
|
||
// battery icons and made them unreadable. Now that the app bar is
|
||
// pinned — white, and already padded by `MediaQuery.padding.top` —
|
||
// nothing can reach that strip, and a second white rectangle painted
|
||
// over a white bar every frame is just cost.
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== BOTTOM SHEETS ====================
|
||
|
||
// The "Trip Map" sheet (`_showmapDetailsSheet`) that lived here is deleted:
|
||
// zero callers — its job (map + booking facts for one stop) has belonged to
|
||
// [StopDetailSheet] since the timeline rework, and it still carried the
|
||
// pre-redesign idiom: red-tinted bordered boxes, a 36pt cancel icon, and
|
||
// 'Booking: #id' as the subtitle of a sheet a rider opens to see a map.
|
||
|
||
Widget _buildDetailRow(String label, String value) {
|
||
final Color primaryRed = ColorConstants.primary;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
// crossAxisAlignment.start keeps the label top-aligned when a long value
|
||
// (e.g. a full pickup address) wraps onto several lines.
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: ColorConstants.onSurfaceVariant,
|
||
fontWeight: FontWeight.w500,
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// Expanded + soft-wrap: the value takes the remaining width and flows
|
||
// onto the next line instead of overflowing off the right edge.
|
||
Expanded(
|
||
child: Text(
|
||
value,
|
||
textAlign: TextAlign.right,
|
||
softWrap: true,
|
||
style: TextStyle(
|
||
color:
|
||
value.contains('Arrived') ||
|
||
value.contains('Picked') ||
|
||
value.contains('Accepted')
|
||
? primaryRed
|
||
: ColorConstants.onSurface,
|
||
fontWeight: FontWeight.w500,
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
height: 1.35,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showProductDetailsSheet(
|
||
BuildContext context, [
|
||
Map<String, dynamic>? item,
|
||
]) {
|
||
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.
|
||
// What the parcel IS leads; the reference is last — it is the one line the
|
||
// rider never decides anything on, read only when the office asks for it.
|
||
final rows = <MapEntry<String, String>>[
|
||
MapEntry(
|
||
'Product',
|
||
val(['description', 'productname', 'product', 'tenantname']),
|
||
),
|
||
MapEntry('Quantity', val(['Quantity', 'quantity'])),
|
||
MapEntry('Weight', () {
|
||
final w = val(['weight', 'Weight']);
|
||
return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg');
|
||
}()),
|
||
MapEntry('Amount', () {
|
||
final a = val(['collectionamt', 'pickupamt']);
|
||
return a.isEmpty ? '' : '₹$a';
|
||
}()),
|
||
MapEntry('Pickup point', val(['pickupcustomer', 'tenantname'])),
|
||
MapEntry('Address', val(['pickupaddress', 'PickupAddress'])),
|
||
MapEntry('Contact', val(['pickupcontactno'])),
|
||
MapEntry('Notes', val(['notes', 'Notes'])),
|
||
MapEntry('Order ID', val(['orderid', 'OrderId'])),
|
||
].where((e) => e.value.isNotEmpty).toList();
|
||
|
||
// Through the kit. The old sheet drew "Product Details" beside a 32pt red
|
||
// cancel IconButton (the handle and the barrier already dismiss), and boxed
|
||
// the rows in a bordered panel — a container inside a container, on the one
|
||
// page family that has none. The rows are a plain ruled list now, and an
|
||
// empty payload is one quiet sentence rather than a 40pt-padded red box.
|
||
showMilerSheet<void>(
|
||
context,
|
||
builder: (context) => MilerSheetScaffold(
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const MilerSheetHeader(
|
||
title: 'Product details',
|
||
icon: LucideIcons.package,
|
||
),
|
||
SizedBox(height: 14.h),
|
||
if (rows.isEmpty)
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||
child: Text(
|
||
'No product details on this booking.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
)
|
||
else
|
||
for (int i = 0; i < rows.length; i++) ...[
|
||
_buildDetailRow(rows[i].key, rows[i].value),
|
||
if (i != rows.length - 1)
|
||
Divider(height: 12, color: ColorConstants.borderSubtle),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|