Files
doormile_milderapp/lib/views/Dashboard/home/homepage.dart
2026-09-09 12:55:23 +05:30

4562 lines
189 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/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_surface.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/home_header.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/heartbeat.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/order_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/pickup_locations.dart';
import 'package:miler/data/service_day.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.
/// ── Home's ground, on the Narrative ladder ──
///
/// It read `daylightSurface` — the heavy slate that was right when a card was
/// a bare white fill and the tonal step was the only thing separating the
/// two. Cards carry a hairline and a lift now, so the ground is the brief's
/// light canvas and the two halves of the trade are in the same place. One
/// alias, so Home cannot drift from the other three tabs again.
/// ── The page is white ──
///
/// It was [MilerSurface.canvas] — #DEE3EA — chosen when Home's rows sat
/// directly on the page and needed a ground to separate from. They do not any
/// more: the run brief and every stop are cards with their own edge and their
/// own lift, so the grey was a second separator under things that already had
/// one, and it made a screen of white cards read as heavy.
///
/// White, and the cards do the separating. It is also what the brand red
/// needs to look like an accent rather than a fourth surface tone.
static Color get _surfaceColor => ColorConstants.pureSurface;
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>{};
/// `next_action` per order, as the pivot returned it. Home reads it only to
/// count what is waiting on the other tab — the two screens must hand
/// [WorkBoundary] the same arguments or their counts drift apart.
Map<String, String> _pivotActions = <String, String>{};
/// Orders the rider has written off today — delivered, cancelled or skipped.
///
/// The union of the completed and skipped stores, both of which record a
/// mutation only after it came back OK. Held here so Home's counters can ask
/// [WorkBoundary] the same question the Deliveries tab asks, with the same
/// arguments. See [_acceptedWaitingCount].
Set<String> _closedIds = <String>{};
Set<String> _rejectedIds = <String>{};
/// Notices when the day underneath the screen has moved on.
///
/// [_fetchQueues] filters against `ServiceDay.today` *as read at fetch time*,
/// so the route on screen is only ever as fresh as the last fetch. See
/// [_rolloverCheck].
final ServiceDayRollover _day = ServiceDayRollover();
/// Fires at the next local midnight. See [_armRollover].
Timer? _rollover;
/// Which trip tab is open.
int _selectedTripIndex = 0;
/// True once the rider has picked a tab himself. See [_settleTripTab].
bool _tripTabChosen = false;
/// Stops the rider has ticked, for "accept just these".
Set<String> _selectedStopIds = <String>{};
// ==================== LIFECYCLE METHODS ====================
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
BottomPage.currentTab.removeListener(_onTabChanged);
final keys = List<String>.from(_pickupTimers.keys);
for (final k in keys) {
_stopPickupPosting(k);
}
_pollerSubscription?.cancel();
_locationTimer?.cancel();
_rollover?.cancel();
_httpClient.close();
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadName();
_loadPickupsToday();
_ensureLocationPermission();
_ensureCameraPermission();
_startPolling();
_armRollover();
// What the counters are actually called, joined on the location id the
// booking already carries. One request, cached, and every failure resolves
// to "use the row's own name" — see [PickupLocations].
unawaited(PickupLocations.ensureLoaded());
// The building the logistics run is headed by — see [RiderHub]. Read from
// prefs the login wrote, so it costs nothing and cannot fail the fetch.
unawaited(RiderHub.ensureLoaded());
BottomPage.currentTab.addListener(_onTabChanged);
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 ====================
/// The error radius, in metres, of the fix [_getValidCoordinates] last took.
/// Credited to the rider by [_isNearPickupLocation], the same way
/// `PickupsController._checkGeofence` credits its own.
double _lastFixAccuracy = 0;
/// ── `low` was fine for telemetry and is not fine for a 10 m fence ──
///
/// This asked for [LocationAccuracy.low] — a ~1 km hint on Android — and
/// fell back to a last-known position of any age. The pair it returns is
/// then used for two different jobs: it rides along on the status payload,
/// where a rough fix costs nothing, and it is measured against the geofence,
/// where a rough fix is not a measurement at all.
///
/// So it takes a real one while the fence is enforced, and keeps the cheap
/// path when it is not. A cached fix is accepted only if it is fresher than
/// [kGeofenceFixMaxAge] — on a round, a stale one is reliably the previous
/// stop.
Future<(String lat, String lng)?> _getValidCoordinates() async {
try {
await _ensureLocationPermission();
Position? pos;
try {
pos = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: kGeofenceEnforced
? LocationAccuracy.best
: LocationAccuracy.low,
timeLimit: Duration(seconds: kGeofenceEnforced ? 8 : 3),
),
);
} catch (_) {
final cached = await Geolocator.getLastKnownPosition();
final stale =
cached != null &&
kGeofenceEnforced &&
DateTime.now().difference(cached.timestamp) > kGeofenceFixMaxAge;
pos = stale ? null : cached;
}
_lastFixAccuracy = pos?.accuracy ?? 0;
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 your office 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. This one guards
// the *bulk* "mark selected as arrived" action on Home, and it runs before
// the controller is ever called.
//
// It honours the same switch and, since this change, the same **radius**.
// It was hardcoded to 500 m while the controller read a configured 100 —
// so the app had two fences of different sizes on two routes into the same
// rung, and which one a rider met depended on whether he had ticked boxes
// or slid a sheet. One number now: [kGeofenceRadiusMeters].
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,
);
// The phone's own error is credited to the rider here too — see
// [kGeofenceRadiusMeters] for why a 10 m fence measured with a ±20 m fix
// has to, and `_getValidCoordinates` for the fix it is measured with.
final double slack = _lastFixAccuracy;
return (distanceInMeters - slack) <= kGeofenceRadiusMeters;
} 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.w700,
fontFamily: FontConstants.fontFamily,
color: _textPrimary,
),
),
),
],
),
content: Text(
specificMessage ??
'You must be within '
'${kGeofenceRadiusMeters.toStringAsFixed(0)} metres 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.w700,
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.w700,
),
),
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);
// Before the early return below: a resume inside the same foreground state
// still has to notice a midnight that passed while the phone was in the
// rider's pocket, and that is the case the timer cannot cover — both
// platforms suspend timers in the background.
if (state == AppLifecycleState.resumed) _rolloverCheck();
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();
}
}
/// Home's index in the bottom bar.
static const int _homeTabIndex = 0;
/// Re-reads the day the moment Home becomes the visible tab.
///
/// ── Why a 30-second poll was not good enough ──
///
/// Home polls on the shared interval — 30s waiting for the hub, 60s off duty
/// — which is right for work arriving *from* the hub and wrong for work the
/// rider just finished with his own hands on the next tab along. He
/// completed a delivery, watched Deliveries go 22 → 21, tapped Home, and the
/// pill said 22 for up to another half-minute.
///
/// Deliveries and Activity have both listened for this for a while
/// (`MyPickups._onTabChanged`, `ActivityPage`); Home was the one screen that
/// did not, which is why it was also the one screen showing a stale figure.
void _onTabChanged() {
if (BottomPage.currentTab.value == _homeTabIndex &&
mounted &&
!_isFetchingQueues) {
_fetchQueues();
}
}
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();
final pivotActions = await getPivotNextActions();
// 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();
// Kept, because the pill's count needs the same answer the Deliveries
// tab builds its list from — see [_acceptedWaitingCount]. Assigned
// directly rather than through `setState`: this runs inside a fetch that
// already rebuilds, and the field is only ever read during that build.
_closedIds = {...completedIds, ...skippedIds};
// 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.
// Through the day filter as well. The accepted store is deliberately
// *not* pruned to today — it is "what have I taken on", and an id set
// that forgets a stop would flip that stop back to pending under the
// rider's thumb — but the route brief above the run is a statement about
// **this** day's kilometres and parcels. Yesterday's accepted-and-unclosed
// booking belongs in the set and not in that total.
//
// Only the rows are filtered; `acceptedIds` above is left whole.
final acceptedStops = _todayOnly(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 = _todayOnly(
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.
// ── The rider's own arrivals ──
//
// Read alongside the other local sets and applied with them, because
// `reached` does not persist the rung: without this the queue reports
// `Miler_Assigned` for a stop the rider is standing at and the row drops
// back to ACCEPTED. See [getArrivedOrderIds] for the whole of it.
final arrivedIds = await getArrivedOrderIds();
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';
} else if (arrivedIds.contains(id) && _localArrivalMayShow(raw)) {
// Below the three above on purpose. Arrival is the *weakest* of the
// local records — it is the one the server could not confirm — so
// anything the server does know about this stop outranks it.
s['orderstatus'] = 'arrived';
}
}
// 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;
_pivotActions = pivotActions;
_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;
}
}
});
// After the state is applied, because the slots are derived from it.
_settleTripTab();
} catch (e) {
debugPrint('[FETCH_QUEUES] Error: $e');
if (mounted) setState(() => _fetchFailed = true);
} finally {
_isFetchingQueues = false;
if (!_queuesLoaded && mounted) {
setState(() => _queuesLoaded = true);
}
}
}
/// Opens Home on a tab that has work on it.
///
/// ── Why this became necessary ──
///
/// The tab index is a **day part**, not a position: slot 0 is the morning
/// whether or not the rider has morning work. That is the right model, and
/// while nothing could place a trip by day part it cost nothing — every trip
/// fell into the undated bucket and landed on Trip 1, so Trip 1 was where the
/// work was.
///
/// Now that [Trip.dayPartTimeOf] can place them, a rider whose whole day is
/// an afternoon run has his work under Trip 2 — correctly — and Home opened
/// on Trip 1 and told him his hub had assigned him nothing. The fix for one
/// screen must not hand him a worse version of the same lie.
///
/// So the landing tab is the first slot that actually holds stops. Three
/// things keep it from fighting the rider:
///
/// • It gives up the moment he taps a tab himself — [_tripTabChosen]. An
/// empty Trip 3 he chose deliberately is an answer he asked for.
/// • It gives up the moment the tab it is on has work, so it lands once and
/// then stops moving under him.
/// • It never *hides* a slot. The tabs still show all three, empty ones
/// included, because "the day has room left in it" is a fact he plans
/// around.
void _settleTripTab() {
if (_tripTabChosen || !mounted) return;
final trips = _trips;
final here = trips.tripAt(_selectedTripIndex);
if (here != null && here.stops.isNotEmpty) return;
final slots = trips.slots;
for (var i = 0; i < slots.length; i++) {
final t = slots[i];
if (t == null || t.stops.isEmpty) continue;
if (i != _selectedTripIndex) {
setState(() {
_selectedTripIndex = i;
_selectedStopIds.clear();
});
}
return;
}
}
/// ── Home is today's work, and only today's ──
///
/// The rule, and the reasoning behind every part of it, is
/// [ServiceDay.onlyToday] — shared with the Deliveries queue, because the two
/// screens showing the rider's live work must not disagree about which day it
/// is. What is local here is only *which* day: the one the screen is
/// currently showing, adopted so the midnight check below compares against
/// the pixels rather than reading the wall clock twice.
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
_day.rollIfNeeded();
return ServiceDay.onlyToday(stops, now: _day.day, where: 'HOME');
}
/// ── The day ending under a screen that is already open ──
///
/// [ServiceDay] answers "which day is this row" at read time, which is right
/// for the data and not sufficient for the screen: [_fetchQueues] filters
/// against the day *as read when it ran*. A rider who leaves Home open
/// through 23:59, or backgrounds the app on it and picks the phone up at six,
/// is looking at a route filtered against a day that has ended.
///
/// Two triggers, because a phone gives no single reliable one — a timer to
/// the next local midnight for the app left in the foreground, and resume for
/// every other case. Both land in [_rolloverCheck], which is idempotent.
/// The same arrangement `ActivityPage` uses, for the same reason.
void _armRollover() {
_rollover?.cancel();
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
}
/// Turns Home over when the service day has moved on.
///
/// Yesterday's stops are cleared **before** the refetch, not after it. A new
/// day starts empty even with no signal, because the alternative is a rider
/// in a dead spot at six in the morning reading yesterday's route as today's
/// assignment — the exact failure this exists to stop. Anything that really
/// is the new day's comes straight back from the fetch.
void _rolloverCheck() {
if (!mounted) return;
_armRollover();
if (!_day.rollIfNeeded()) return;
setState(() {
_allTripStops = <Map<String, dynamic>>[];
_ordersMap = <String, Map<String, dynamic>>{};
_orderIds = <String>[];
_selectedOrders = <String, bool>{};
_selectedStopIds = <String>{};
_acceptedStops = <Map<String, dynamic>>[];
isAllSelected = false;
// The fetch short-circuits on an unchanged payload, and the payload has
// not changed — only the day it is read against has. Clearing the key is
// what lets the new day's answer through.
_lastQueuesJson = '';
});
_fetchQueues();
}
/// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call
/// site reads the same as it did while the rule itself is reachable from a
/// test — a precedence contract that can only be exercised by building the
/// whole page is a contract nobody checks.
static bool _localArrivalMayShow(StopStatus serverSaid) =>
HomepageArrivalPrecedence.localArrivalMayShow(serverSaid);
/// 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(),
};
// ── Through the same day filter as the queue ──
//
// These are merged into the trip by [_tripStops], and a row this call
// carries that the queue does not is **appended** — so an unfinished
// stop still reporting `active` from a shift two days ago walked past
// [_todayOnly] and back onto Home through the side door, wearing a LIVE
// mark. One day, one rule, however the row arrived.
final activeOrders = _todayOnly(
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;
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();
// Already correct — routed through the shared resolver so the 30 lives in
// one place. See [kDefaultLogSeconds] for what a zero here used to cost
// the rider-log loop, which read the same pref and did not guard it.
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
return Duration(seconds: interval);
} catch (_) {
return const Duration(seconds: kDefaultLogSeconds);
}
}
// ==================== 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(_bulkStopFor(id) ?? const {}))
.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,
pivotActions: _pivotActions,
// ── The set the other screen was passing and this one was not ──
//
// Both screens call [WorkBoundary.deliveryQueue], which was supposed to be
// what stopped them disagreeing. It cannot do that when they hand it
// different arguments: Deliveries passed `closedIds`, Home did not, and a
// delivered stop therefore left one list and stayed in the other's count.
// The rider finished a drop, watched the tab go 22 → 21, and came back to
// Home still being told he had 22 in hand.
//
// It is worse than a stale figure here, because Home *stamps* a closed
// stop `picked` (see the local-records loop in `_fetchBookings`) so its
// row cannot flicker back to LIVE while the queue catches up. `picked` is
// squarely inside the delivery domain, so the pill was counting the stamp
// that was put there to hide it.
closedIds: _closedIds,
).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 [TripSelector] in the header.
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.
/// ── Both filters here were keyed on the undecided list ──
///
/// `_ordersMap.containsKey` dropped every already-accepted id before the
/// action ran, and `_selectedOrders` was then rebuilt over `_orderIds` —
/// also undecided-only — so an accepted id could not survive into
/// [_handleBulkAction] by either route. The selection bar could accept and
/// nothing else, whatever its label said.
///
/// Both now resolve through [_bulkStopFor], so a ticked set that has already
/// been accepted arrives intact and the action advances it to arrived, and
/// then to picked. Which rung it is remains [_handleBulkAction]'s decision,
/// read off the stops themselves.
Future<void> _acceptSelectedStops(List<String> orderIds) async {
final ids = orderIds.where((id) => _bulkStopFor(id) != null).toList();
if (ids.isEmpty || _acceptingTripId != null) return;
setState(() {
_acceptingTripId = 'selection';
_selectedOrders = {for (final id in ids) id: true};
});
try {
await _handleBulkAction();
} 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.
/// Advances one stop a rung.
///
/// Returns null when the rung was taken, and **the reason** when the server
/// refused it — see the PICKED branch below. It used to return `void`, so a
/// refused pivot left the stop on Home with nothing on screen to say why and
/// the confirmation sheet reporting success over the top of it.
Future<String?> _advanceStop(
Map<String, dynamic> stop,
String nextStatus,
) async {
final orderId = (stop['orderid'] ?? '').toString();
if (orderId.isEmpty) return null;
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');
// Handing the reason back rather than dropping it: a rider whose
// pickup the hub refused has to be told, or the only thing he can do
// is slide again at a stop that will keep refusing. See
// [PickupsController.lastPickupRefusal].
if (nextStatus == 'PICKED') return dc.lastPickupRefusal ?? 'refused';
}
// ── 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';
// ── And it has to survive the refresh this sheet is about to run ──
//
// The line above is a field on an in-memory row. `reached` does not
// persist the rung (request 15), so the `_fetchQueues()` at the foot of
// `_advanceStopWithSheet` rebuilt this row from a server that still
// says `Miler_Assigned` and put it straight back on ACCEPTED — about a
// second after the rider slid the sheet. See [getArrivedOrderIds].
await addArrivedOrderIds([orderId]);
} else {
// ── The rung is PICKED, whatever the pivot released ──
//
// This used to take the rung from `lastPivotConsignmentStatus`, so in
// compatibility mode — `MILER_COLLECTED_STATE_ENABLED` off, where
// `pickup-complete` releases the consignment itself — a collection
// landed on `Out_for_Delivery` and the rider's card jumped from
// ACCEPTED straight to ACTIVE. He never saw PICKED, and the round he
// had not started yet was already on the road.
//
// Which state the *server* is in is still recorded on the row, because
// `deliver` is gated on it and lying about it is how a hand-over gets
// refused at a door. What the rider is shown is his own ladder:
// collected here, released when he presses **Start delivery**. The two
// are allowed to differ — see `MyPickups._needsRelease`, which is the
// other half of this and the reason the difference is safe.
final produced = ConsignmentState.values
.where((s) => s.name == dc.lastPivotConsignmentStatus)
.firstOrNull;
stop['orderstatus'] = 'picked';
if (produced != null) stop['consignmentstatus'] = produced.name;
// Off the arrived rung the moment it is collected, so the local
// record can never outlive the stop it describes.
await removeArrivedOrderIds([orderId]);
}
// 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 null;
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);
}
return null;
} catch (e) {
debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e');
return nextStatus == 'PICKED'
? 'Could not record this pickup — check your connection and try '
'again.'
: null;
}
}
/// 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,
// Both, because this answers *which rung is the stop on* and the answer
// is the same for either: he is at the counter and the next thing he does
// is collect. Splitting [StopState.arrived] out of `active` was about the
// word the row prints, not about what the sheet offers next.
StopState.arrived || 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: OrderManifest.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,
];
// The first refusal is the answer for the whole batch: they are one
// counter, one slide and one server call each, so a hub that
// refused the first has refused the event.
String? refusal;
for (final s in loaded) {
refusal ??= await _advanceStop(
s,
rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED',
);
}
if (refusal != null) return refusal;
if (rung == StopStatus.arrived) {
await saveOrderLabels({
for (final line in OrderManifest.forGroup(loaded))
line.orderId: line.label,
});
}
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,
// He is at the counter — the next thing he does is collect. Offering
// *Accept* here, which is what the missing arrived case produced, sends
// him back two rungs on a stop he is standing at.
StopState.arrived || 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;
}
/// The stop a bulk selection refers to, wherever it currently lives.
///
/// ── Why [_ordersMap] on its own could only ever accept ──
///
/// `_ordersMap` is built from `validOrders`, which is deliberately the
/// *undecided* stops only — so a stop leaves it the moment it is accepted.
/// Every rung past ACCEPT looked its stop up there, got null and `continue`d,
/// and the label is derived from the same map: a selection of accepted stops
/// read back as `ACCEPT`, so the button said **Accept Bookings** and posted
/// another accept. `Move to Arrived` and `Move to Picked` were unreachable
/// from the selection bar even though [_handleBulkAction] implements both.
///
/// [_allTripStops] keeps every stop in every state, which is exactly what the
/// rungs past acceptance need. On a milk run the order stays on Home to be
/// walked accepted → arrived → picked — see [MilkRun].
Map<String, dynamic>? _bulkStopFor(String orderId) =>
_ordersMap[orderId] ?? _stopById(orderId);
/// 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
// [OrderManifest].
lines: OrderManifest.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,
];
String? refusal;
for (final stop in loaded) {
refusal ??= await _advanceStop(stop, action.status);
}
if (refusal != null) return refusal;
// ── 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 [OrderManifest].
if (action == SelectionAction.pickUp) {
await saveOrderLabels({
for (final line in OrderManifest.forGroup(loaded))
line.orderId: line.label,
});
}
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.isAtSource) 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 — your office has been told'
: '${stops.length} stops rejected — your office 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
? 'Your office needs a reason to re-route it to someone else.'
: 'Your office 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 your office'
: '${taken.length} acceptances undone — back with your office',
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.w700,
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.w800,
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 [];
/// Walks the ticked stops up one rung: ACCEPT → ACCEPTED → ARRIVED → PICKED.
///
/// ── Where the rider ends up is the line's decision, not the caller's ──
///
/// This took a `navigateToBookings` flag, defaulted to `true`, and the two
/// branches it selected disagreed about the thing [ServiceProfile] already
/// answers. The tick-based path passed `false` and got the profile-aware
/// branch; the bulk action button passed nothing, got the default, and hit an
/// unconditional `Get.offAll(BottomPage(initialIndex: 1))`.
///
/// On a **milk run** that is the wrong move and it broke the whole ladder.
/// The rider accepts at a kitchen, and the order has to stay on Home to be
/// walked to arrived and then picked — see [MilkRun]. Throwing him at the
/// work tab on accept sent him to a tab that was correctly empty, made the
/// accept look like it had filed his order somewhere else, and left the two
/// rungs that follow it unreachable from where he was standing.
///
/// So the flag is gone. `handsOffAtCollection` is the one rule and it is read
/// in one place: the line that hands off at *acceptance* (logistics) moves
/// him, the line that hands off at *collection* (milk man) keeps him on Home.
Future<void> _handleBulkAction() async {
final selectedOrderIds = _selectedOrders.entries
.where((e) => e.value)
.map((e) => e.key)
.toList();
if (selectedOrderIds.isEmpty) return;
final statuses = selectedOrderIds
.map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {}))
.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 = [];
/// Orders this batch confirmed as collected, mirrored into [_collectedIds]
/// once the loop is done so the cards re-read their leg immediately rather
/// than at the next poll.
final Set<String> bulkCollected = <String>{};
for (final orderId in selectedOrderIds) {
// Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every
// rung after the first was silently skipped here. See [_bulkStopFor].
final Booking = _bulkStopFor(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,
);
// ── The bulk path recorded nothing at all ──
//
// Accept hides the row, PICKED writes two stores; ARRIVED wrote
// neither, and this method finishes with `_fetchQueues()`. So a
// successful bulk arrival was a network call, a success count and
// a row that came straight back on ACCEPTED — which is what "mark
// as arrived does nothing" looked like from the outside.
if (success) {
Booking['orderstatus'] = 'arrived';
await addArrivedOrderIds([orderId]);
}
} 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);
// ── The write that says the load is in his hands ──
//
// `_advanceStop` records this rung in the collected store and
// this branch did not, so a bag picked from the **selection bar**
// was never marked as carried. That one omission is what put
// **Start Pickup** on a delivery: [MilkRun.workingKind] reads
// exactly this set to decide which leg a stop is on, and the
// booking adapter stamps `type: pickup` on every row, so with an
// empty set a bag already in the box still described itself as a
// collection. The same answer feeds `toDrop` on the queue's
// distance, so the card also measured to the kitchen he had just
// left instead of to the customer's door — which is the "km and
// time are wrong" half of it.
//
// Recorded only after the backend confirmed, same rule as accept.
await addCollectedOrderIds([orderId]);
bulkCollected.add(orderId);
// One copy of the day, and it is now stale — Deliveries must not
// render this order from a response that predates the hand-over.
unawaited(WorkRepository.instance.invalidate());
// ── Picked is a hand-over on one line and a finish on the other ──
//
// This branch closed the booking outright. That is right for
// **logistics**, where what he collects goes to the hub and the
// booking's own story ends at the counter.
//
// On a **milk run** it is wrong and it loses the order: collection
// is the *middle* of the day — every bag is followed by a round of
// drops he makes himself — so filing it as completed sent it to
// Activity instead of to Deliveries, and the delivery leg had
// nothing to work. See [ServiceProfile.handsOffAtCollection].
if (ServiceProfile.active.handsOffAtCollection) {
await addAcceptedBookings([Booking]);
} else {
await removeAcceptedBookings([orderId]);
await addCompletedBookings([Booking]);
}
// Off the arrived rung — it has been collected.
await removeArrivedOrderIds([orderId]);
}
}
} catch (e) {
debugPrint('[BULK] Error updating Booking $orderId: $e');
success = false;
}
if (success) {
successCount++;
} else {
debugPrint('[BULK] Failed to update Booking $orderId');
}
}
// The leg every card reads is derived from this set, so it has to land
// before the next build — not at the next poll three seconds later.
if (bulkCollected.isNotEmpty && mounted) {
setState(() => _collectedIds = {..._collectedIds, ...bulkCollected});
}
// 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 — your office 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') {
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;
}
}
/// 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,
);
/// A trip with nothing in it, for a slot the hub has not filled.
///
/// Every figure the brief draws is derived from the stops, so an empty list
/// gives `0 stops left`, a progress track at zero and an em dash under
/// duration, distance and payment on its own — there is no "empty" variant of
/// the brief to keep in step with the real one, because this *is* the real
/// one with nothing in it.
Trip _unassignedTrip() => Trip.fromStops(
id: 'unassigned-$_selectedTripIndex',
stops: const [],
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
hubLng: _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,
// Home is the one caller that holds the whole route, so it is the one
// that can say `Stop 3 of 8`.
stopCount: trip.stops.length,
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
),
// ── An order tapped inside a kitchen is about its own door ──
//
// Same sheet, different subject. The counter is shared by every order in
// the group and already has a surface of its own — the preview behind the
// card's third utility, which draws the whole leg to it with the manifest
// waiting there. So the order's sheet points at the customer instead of
// repeating the kitchen five times. See [StopDetailSheet.ofOrder].
onOrderTap: (stop) => StopDetailSheet.show(
context,
stop: stop,
stopNumber: trip.stops.indexOf(stop) + 1,
stopCount: trip.stops.length,
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
ofOrder: true,
),
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.
/// ── The header's data, assembled ──
///
/// The header itself is [HomeHeaderDelegate] — a pinned sliver inside the
/// page's own `CustomScrollView`, not a fixed block above it. This composes
/// the strings it draws, from the state the page already holds. No new
/// source, no extra call: `_trips` is the same getter `build` reads and the
/// duty flags are the same fields the old switch read.
SliverPersistentHeader _buildHeaderSliver(BuildContext context) {
final String firstName = _userName.isNotEmpty
? _userName.split(' ').first
: 'Rider';
final List<Trip> headerTrips = _trips;
final Trip? current = headerTrips.tripAt(_selectedTripIndex);
// The one figure the whole shift is steered by, computed once and shown
// once. The run card below used to print the same arithmetic again.
final int total = current?.stopCount ?? 0;
final int done =
current
?.states(acceptedIds: _acceptedIds, rejectedIds: _rejectedIds)
.where((st) => st.isResolved)
.length ??
0;
final int left = (total - done).clamp(0, total);
final String subtitle = total == 0
? (isOnline ? 'Waiting for work' : 'You are off duty')
: left == 0
? 'Run complete'
: left == 1
? '1 stop remaining'
: '$left stops remaining';
// `Trip 1 · 17 left · 33/50` — the collapsed line, from the same three
// figures the expanded tabs draw, so the two states cannot drift.
final String collapsed = total == 0
? '${Trip.tabLabel(_selectedTripIndex)} · $subtitle'
: '${Trip.tabLabel(_selectedTripIndex)} · $left left · $done/$total';
return SliverPersistentHeader(
pinned: true,
delegate: HomeHeaderDelegate(
topInset: MediaQuery.paddingOf(context).top,
expandedContent: HomeHeaderDelegate.expandedContentFor(context),
greeting: '${_partOfDay()}, $firstName',
subtitle: subtitle,
onDuty: isOnline,
// Unchanged rules, new presentation: duty is locked mid-pickup because
// going off would strand a customer, and refused while a write is in
// flight.
dutyLocked: _hasActivePickup(),
dutyBusy: _isToggling,
// The confirmation sheet still owns the decision — see
// [_confirmOnlineOffline]. Nothing here changes duty by itself.
onToggleDuty: _confirmOnlineOffline,
collapsedSummary: collapsed,
),
);
}
/// `Good morning` / `Good afternoon` / `Good evening`.
///
/// The greeting was `Hi, Rajan` — a word with no information in it. The time
/// of day costs the same line and tells a rider on a split shift which half
/// of it he is in.
String _partOfDay() {
final hour = DateTime.now().hour;
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
}
/// ── Duty control ──
///
/// The presentation lives in [DutyPill] now, in the header file. What was
/// here was `AnimatedToggleSwitch.dual` — a 34pt two-position track carrying
/// the words *On duty* and *Off duty* — which is a settings control, and was
/// the second loudest object in a header whose subject is the work. See the
/// pill for the reasoning; the package it used is no longer imported by this
/// screen.
///
/// **None of the duty logic moved.** [_confirmOnlineOffline] still owns the
/// decision, the confirmation sheet still runs, `_isToggling` still gates a
/// second press and `_hasActivePickup()` still locks it mid-stop.
/// 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: 450.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.w800,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
'Stay on duty new bookings appear here the moment '
'your office assigns you.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.45,
fontWeight: FontWeight.w600,
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.w700,
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.w700,
fontFamily: FontConstants.fontFamily,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: _primaryRed,
foregroundColor: Colors.white,
elevation: 0,
// Mid-size: content-hugging but with a comfortable min width,
// so it's neither full-width nor a tiny chip. The height is
// the unscaled [ButtonSizes.compact] rather than `44.h` —
// ScreenUtil would shrink a scaled 44 below the tap-target
// floor on a short phone, which is where it matters most.
minimumSize: Size(150.w, ButtonSizes.compact),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 11.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
),
),
],
),
);
}
// ── `_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.w700,
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.w700,
),
),
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: [
// ── One scroll view, and the header is a sliver in it ──
//
// It was a `Column`: the header as a fixed first child, the scroll
// view as the second. That pinned the header by construction and
// charged its full height — 214pt with the status bar — at every
// scroll position, which is why the rider's first stop started
// below the fold.
//
// As a pinned sliver it holds the top of the screen the same way,
// and collapses to 52pt of operational context on the way down.
// Native `SliverPersistentHeader`; no package was added for it.
// See [HomeHeaderDelegate].
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: RefreshIndicator(
onRefresh: _handleRefresh,
color: ColorConstants.primary,
// The spinner comes down below the pinned header rather than
// out from behind it.
edgeOffset:
MediaQuery.paddingOf(context).top +
HomeHeaderDelegate.collapsedContent,
child: CustomScrollView(
// ── Physics, and why it is not `Bouncing` ──
//
// `BouncingScrollPhysics` forced the iOS rubber-band on every
// platform, and rubber-band overscroll is unbounded — so a
// pull dragged the whole page off the top of the screen and
// held it against a blank gap. Plain
// `AlwaysScrollableScrollPhysics` lets `applyTo` compose with
// the platform default (clamping on Android, bouncing on
// iOS), while "always scrollable" keeps the pull working on a
// short list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
_buildHeaderSliver(context),
// ── The trip tabs, under the header rather than in it ──
//
// They govern what the page below shows, so they belong to
// the page. Inside the header they were the one control on
// the screen that changes the content, living in the one
// surface that never does — and the red underline could
// only be white, because it was already on red.
//
// Not pinned. The header keeps `Trip 1 · 17 left · 33/50`
// when it collapses, which is what a rider scrolling needs;
// three tabs held permanently would be the same information
// twice, in the space the work is supposed to be in.
// The tabs are no longer a sliver of their own. They are
// the head of the panel that holds the trip, and that panel
// is built with the work below — see the `else` branch.
// ══════════════════════════════════════════════════════════
// READING ORDER
//
// [app bar] fixed ← greeting, duty, 1·2·3
// trip summary scrolls away (ring, slot, load, metrics)
// hint line scrolls
// HUB → stops → HUB scrolls
// accept bar scrolls
//
// Nothing in this scroll view is pinned any more. Everything here
// is reference material the rider reads once and then works from
// memory, so all of it is allowed to leave. The one control that
// governs the whole screen — which trip he is looking at — moved
// into the header, which is fixed by being the Column's first
// child rather than by being a sliver. See [TripSelector].
// ══════════════════════════════════════════════════════════
// 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 ──
//
// Gone from here. It was a pinned
// `SliverPersistentHeader` on the canvas, in a
// strip of its own between the brand bar and the
// page — a third surface down the top of the
// screen whose only job was to hold a control. It
// is inside the header now, which was already
// pinned, so the strip, its hairline and its height
// arithmetic all went with it. See [TripSelector].
// 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(),
),
),
)
// ── The panel is the page ──
//
// Tabs and trip used to be two slivers: a control in one
// box, the work it governs in the next, with nothing but
// adjacency to say they were related. The panel encloses
// the run now — the selected tab is the top edge of the
// brand surface the brief, the route and the action bar all
// sit inside, so which trip is on screen is said once, by
// the shape holding it.
//
// Still not pinned. The header keeps
// `Trip 1 · 17 left · 33/50` when it collapses, which is
// what a rider scrolling needs; a tab strip held
// permanently would be the same information twice, in the
// space the work is supposed to be in.
// ── The tabs are attached to the trip's figures ──
//
// Tabs and brief used to be two boxes: a control in one
// sliver, the figures it governs in the next, with nothing
// but adjacency to say they were related. They are one
// panel now — the selected tab is the top edge of the glass
// the brief sits on, so which trip is on screen is said
// once, by the shape holding it.
//
// The route and the action bar stay outside it, on the
// canvas with their own white panel. A version of this put
// the whole page inside the panel and it was too much: the
// ground a rider reads a route off should be the quietest
// thing on the screen, not the loudest.
//
// Still not pinned. The header keeps
// `Trip 1 · 17 left · 33/50` when it collapses, which is
// what a rider scrolling needs; a tab strip held
// permanently would be the same information twice, in the
// space the work is supposed to be in.
else ...[
SliverToBoxAdapter(
// Air above, and the panel takes the same gutter as
// every other layer-1 surface on Home. The inset inside
// it is [TripSelector.contentInset], so the brief's copy
// lands on the same vertical as `TODAY'S RUN` below.
child: Padding(
padding: EdgeInsets.fromLTRB(
MilerSurface.panelGutter,
12.h,
MilerSurface.panelGutter,
0,
),
child: TripSelector(
trips: trips,
selectedIndex: _selectedTripIndex,
acceptedIds: _acceptedIds,
rejectedIds: _rejectedIds,
// Selection is per-trip, so the ticks do not travel
// with the rider between trips — a tick made on
// Trip 1 must not be counted by the bar while Trip 2
// is on screen. See [_selectedPendingIds].
onSelect: (index) {
// His choice from here on, even if it is an empty
// slot — see [_settleTripTab].
_tripTabChosen = true;
if (index == _selectedTripIndex) return;
setState(() {
_selectedTripIndex = index;
_selectedStopIds.clear();
});
},
// ── An unfilled slot gets the same panel ──
//
// It used to get a different one: a line of its own
// reading `Not assigned yet`, which made the panel
// a 32pt stub under a tab twice its height, and
// changed the shape of the screen depending on
// whether the hub had got to this slot yet.
//
// The brief of an empty slot is a real brief — it
// is just all zeroes. `0 stops left`, an empty
// track, and an em dash under every figure that has
// nothing to report. So the panel is the same panel
// at the same height, and when the hub assigns the
// trip the figures fill in where the dashes were
// instead of the page changing shape around the
// rider.
body: SmoothSwap(
child: Reveal(
// Keyed on the trip, so switching tabs plays
// the entrance once and the 5s poll never
// replays it. The empty slot keys on its own
// index for the same reason.
key: ValueKey(
'brief_${trip?.id ?? 'unassigned-$_selectedTripIndex'}',
),
// The panel above is the surface and it is the
// brand bar's red, so the brief drops its own
// card and inverts its ink.
child: _tripCardFor(
trip ?? _unassignedTrip(),
).buildSummary(onBrand: true),
),
),
),
),
),
// The route and the action bar, on the canvas.
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(
key: ValueKey('reveal_${trip.id}'),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// 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.w600,
fontSize: 14.sp,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(width: 12),
// Expanded + soft-wrap: the value takes the remaining width and flows
// onto the next line instead of overflowing off the right edge.
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
softWrap: true,
style: TextStyle(
color:
value.contains('Arrived') ||
value.contains('Picked') ||
value.contains('Accepted')
? primaryRed
: ColorConstants.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14.sp,
fontFamily: FontConstants.fontFamily,
height: 1.35,
),
),
),
],
),
);
}
void _showProductDetailsSheet(
BuildContext context, [
Map<String, dynamic>? item,
]) {
final 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: 1.h),
child: Text(
'No product details on this booking.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
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),
],
],
),
),
),
);
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE ARRIVED PRECEDENCE RULE
///
/// Whether the local ARRIVED record is allowed to speak for a row the server
/// has already described.
///
/// ── A whitelist, because the blacklist leaked ──
///
/// This was three exclusions — not complete, not cancelled, not rejected —
/// and `isWorkComplete` is line-dependent: on a milk run it means *delivered
/// or cancelled* only. So a server row reporting `picked`, `outForDelivery`
/// or `deliveryArrived` passed all three tests and the local record wrote
/// `arrived` straight over it. That is the one thing this store must never
/// do — it would walk a stop the hub knows is on the road back to a rung the
/// rider left an hour ago.
///
/// The rule inverted: the local record may speak **only** where the server
/// has nothing further along to say. Everything the server does say wins,
/// including `arrived` itself, where writing it again is a no-op.
///
/// This is the whole of the precedence contract, in one place, so it can be
/// asserted directly rather than inferred from three negations. **Delete it
/// with the store** — see [getArrivedOrderIds] — once `reached` persists.
abstract final class HomepageArrivalPrecedence {
static bool localArrivalMayShow(StopStatus serverSaid) =>
serverSaid.isPending ||
serverSaid == StopStatus.accepted ||
serverSaid == StopStatus.arrived ||
// A row whose status the app could not parse is a row the server has
// said nothing usable about, which is exactly the gap this store fills.
serverSaid == StopStatus.unknown;
}