Files
doormile_milderapp/lib/views/Dashboard/home/homepage.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

4153 lines
168 KiB
Dart
Raw Blame History

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