Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
4714 lines
197 KiB
Dart
4714 lines
197 KiB
Dart
// ignore_for_file: unused_element, unused_import
|
||
|
||
import 'package:animated_toggle_switch/animated_toggle_switch.dart';
|
||
import 'package:another_flushbar/flushbar.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:lottie/lottie.dart' hide Marker;
|
||
import 'dart:convert';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'dart:async';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'dart:io';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:miler/providers/pickup/pickup_provider.dart';
|
||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||
import 'package:miler/views/Dashboard/orders/orderstaus_button.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||
import 'package:miler/helpers/poll_policy.dart';
|
||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/controllers/profile_controller.dart';
|
||
import 'package:miler/controllers/riderlog.dart';
|
||
import 'package:miler/controllers/logcontroller.dart';
|
||
import 'package:miler/controllers/duty_controller.dart';
|
||
import 'package:miler/Models/stop_status.dart';
|
||
import 'package:miler/controllers/pickup.dart';
|
||
import 'package:miler/controllers/pickups_controller.dart';
|
||
import 'package:miler/controllers/summary_controller.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import 'package:latlong2/latlong.dart' show LatLng;
|
||
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
||
import 'package:geocoding/geocoding.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:floating/floating.dart';
|
||
import 'dart:math' as math;
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:miler/views/Dashboard/home/accepted_pill.dart';
|
||
import 'package:miler/views/Dashboard/home/selection_bar.dart';
|
||
import 'package:miler/views/Dashboard/home/route_brief.dart';
|
||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
|
||
import 'package:miler/views/Dashboard/home/home_header.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_empty_state.dart';
|
||
import 'package:miler/views/Dashboard/home/duty_sheet.dart';
|
||
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
|
||
import 'package:miler/views/Dashboard/pickups/pickups.dart' as pickups;
|
||
import 'package:miler/background/live_tracking_service.dart';
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/api_config.dart';
|
||
import 'package:miler/data/heartbeat.dart';
|
||
import 'package:miler/data/consignment_state.dart';
|
||
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
|
||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||
import 'package:miler/widget/Bottom_page.dart';
|
||
import 'package:miler/data/order_manifest.dart';
|
||
import 'package:miler/data/milk_run.dart';
|
||
import 'package:miler/data/order_events.dart';
|
||
import 'package:miler/data/work_domain.dart';
|
||
import 'package:miler/data/geofence.dart';
|
||
import 'package:miler/data/work_repository.dart';
|
||
import 'package:miler/data/pickup_locations.dart';
|
||
import 'package:miler/data/service_day.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
|
||
import 'package:miler/views/Dashboard/home/route_timeline.dart';
|
||
import 'package:miler/views/Dashboard/home/stop_action_sheet.dart';
|
||
|
||
/// Helper function to launch phone dialer
|
||
Future<bool> launchPhoneDialer(String phoneNumber) async {
|
||
try {
|
||
final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), '');
|
||
if (phone.isEmpty) {
|
||
debugPrint('[PHONE] Empty phone number after sanitization');
|
||
return false;
|
||
}
|
||
final Uri uri = Uri(scheme: 'tel', path: phone);
|
||
try {
|
||
final launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||
if (launched) {
|
||
debugPrint('[PHONE] Successfully launched dialer for: $phone');
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[PHONE] Failed to launch dialer: $e');
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
debugPrint('[PHONE] Error in launchPhoneDialer: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
class Homepage extends StatefulWidget {
|
||
const Homepage({super.key});
|
||
@override
|
||
State<Homepage> createState() => _HomepageState();
|
||
}
|
||
|
||
class _HomepageState extends State<Homepage>
|
||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||
// ==================== MILER BRAND PALETTE ====================
|
||
// Aligned to the shared design tokens (ColorConstants) so Home matches
|
||
// the rest of the app.
|
||
static final Color _primaryRed = ColorConstants.primary; // #960019
|
||
static Color get _darkRed => ColorConstants.primary;
|
||
static Color get _lightRed => ColorConstants.errorLight;
|
||
static final Color _redAccent = ColorConstants.primaryContainer; // #C01227
|
||
static Color get _successColor => ColorConstants.success;
|
||
static Color get _warningColor => ColorConstants.warning;
|
||
// Slate-tinted daylight grey (#F8FAFC), not the old warm #FCF9F8. The warm
|
||
// surface sat ~3% from white, so white cards on it had no visible edge in
|
||
// sunlight — riders reported not being able to tell cards apart.
|
||
/// ── Home's ground, on the Narrative ladder ──
|
||
///
|
||
/// It read `daylightSurface` — the heavy slate that was right when a card was
|
||
/// a bare white fill and the tonal step was the only thing separating the
|
||
/// two. Cards carry a hairline and a lift now, so the ground is the brief's
|
||
/// light canvas and the two halves of the trade are in the same place. One
|
||
/// alias, so Home cannot drift from the other three tabs again.
|
||
/// ── The page is white ──
|
||
///
|
||
/// It was [MilerSurface.canvas] — #DEE3EA — chosen when Home's rows sat
|
||
/// directly on the page and needed a ground to separate from. They do not any
|
||
/// more: the run brief and every stop are cards with their own edge and their
|
||
/// own lift, so the grey was a second separator under things that already had
|
||
/// one, and it made a screen of white cards read as heavy.
|
||
///
|
||
/// White, and the cards do the separating. It is also what the brand red
|
||
/// needs to look like an accent rather than a fourth surface tone.
|
||
static Color get _surfaceColor => ColorConstants.pureSurface;
|
||
static Color get _cardColor => ColorConstants.pureSurface;
|
||
static Color _textPrimary =
|
||
ColorConstants.onSurface; // app-standard near-black
|
||
static Color get _textSecondary => ColorConstants.onSurfaceVariant;
|
||
static final Color _textTertiary = ColorConstants.secondaryText; // #8E8E93
|
||
|
||
// ==================== STATE VARIABLES ====================
|
||
bool isOnline = true;
|
||
String _userName = '';
|
||
String _city = '';
|
||
String _shiftStart = '';
|
||
String _shiftEnd = '';
|
||
int _pickupsToday = 0;
|
||
final PickupProvider _pickupProvider = PickupProvider();
|
||
final PickupController _pickupController = Get.find<PickupController>();
|
||
|
||
Map<String, Map<String, dynamic>> _ordersMap = {};
|
||
List<String> _orderIds = [];
|
||
|
||
StreamSubscription<void>? _pollerSubscription;
|
||
Duration? _currentPollInterval = const Duration(seconds: 0);
|
||
bool _isToggling = false;
|
||
|
||
Map<String, bool> _selectedOrders = {};
|
||
bool isAllSelected = false;
|
||
|
||
String _lastQueuesJson = '';
|
||
bool _loadedOnlineFromPrefs = false;
|
||
bool _isFetchingQueues = false;
|
||
|
||
/// False until the first queue fetch has returned.
|
||
///
|
||
/// Home is trip-shaped, and "no trips" renders as "Trip 1 not assigned yet" —
|
||
/// a definite statement about the rider's day. On a cold open it was made
|
||
/// before the request had come back, so the landing screen of the app told
|
||
/// every rider his hub had given him nothing, then quietly replaced it with
|
||
/// his route. Not knowing yet gets its own state now.
|
||
bool _queuesLoaded = false;
|
||
|
||
/// False while the app is backgrounded. Polling a screen nobody is looking
|
||
/// at spends the rider's battery on information he cannot see.
|
||
bool _pollForeground = true;
|
||
|
||
/// Set when a fetch fails and we have nothing to show. Without it an empty
|
||
/// list after a network error rendered as "Trip 1 not assigned yet" — the app
|
||
/// reporting its own failure to the rider as good news about his workload.
|
||
bool _fetchFailed = false;
|
||
|
||
final Map<String, bool> _pickupRunning = <String, bool>{};
|
||
final Map<String, Timer> _pickupTimers = <String, Timer>{};
|
||
final Map<String, Map<String, dynamic>> _pickupBasePayload =
|
||
<String, Map<String, dynamic>>{};
|
||
final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider();
|
||
final Map<String, bool> _statusBusy = <String, bool>{};
|
||
|
||
Timer? _locationTimer;
|
||
final Set<String> _hiddenOrderIds = <String>{};
|
||
List<Map<String, dynamic>> _activePickups = <Map<String, dynamic>>[];
|
||
|
||
static final http.Client _httpClient = http.Client();
|
||
Position? _lastGeocodedPosition;
|
||
|
||
/// Where the rider currently is. Stands in for the hub he set out from when
|
||
/// measuring the round-trip length of the remaining route.
|
||
double? _riderLat;
|
||
double? _riderLng;
|
||
|
||
/// Stops the rider has accepted (now on the Bookings tab) — off this list,
|
||
/// but still part of the route the header summarises.
|
||
List<Map<String, dynamic>> _acceptedStops = <Map<String, dynamic>>[];
|
||
|
||
/// Id of the trip currently being accepted, so only that card shows a
|
||
/// spinner instead of every card on the screen.
|
||
String? _acceptingTripId;
|
||
|
||
/// Order ids with a single-stop action in flight.
|
||
final Set<String> _busyStopIds = <String>{};
|
||
|
||
/// Every stop the hub assigned today, in any state — the source the trip
|
||
/// cards are built from. See the note in `_fetchQueues`.
|
||
List<Map<String, dynamic>> _allTripStops = <Map<String, dynamic>>[];
|
||
|
||
/// Locally recorded decisions. Optimistic: the rider's tap lands here
|
||
/// immediately so a slow network can never bounce a stop back to pending
|
||
/// under his thumb.
|
||
Set<String> _acceptedIds = <String>{};
|
||
|
||
/// Orders the rider is physically carrying, on a service run.
|
||
///
|
||
/// Local only — the parcel backend has no state between "accepted" and
|
||
/// "picked", and `picked` is the delivery. See the collected store.
|
||
Set<String> _collectedIds = <String>{};
|
||
|
||
/// `next_action` per order, as the pivot returned it. Home reads it only to
|
||
/// count what is waiting on the other tab — the two screens must hand
|
||
/// [WorkBoundary] the same arguments or their counts drift apart.
|
||
Map<String, String> _pivotActions = <String, String>{};
|
||
|
||
/// Orders the rider has written off today — delivered, cancelled or skipped.
|
||
///
|
||
/// The union of the completed and skipped stores, both of which record a
|
||
/// mutation only after it came back OK. Held here so Home's counters can ask
|
||
/// [WorkBoundary] the same question the Deliveries tab asks, with the same
|
||
/// arguments. See [_acceptedWaitingCount].
|
||
Set<String> _closedIds = <String>{};
|
||
Set<String> _rejectedIds = <String>{};
|
||
|
||
/// Notices when the day underneath the screen has moved on.
|
||
///
|
||
/// [_fetchQueues] filters against `ServiceDay.today` *as read at fetch time*,
|
||
/// so the route on screen is only ever as fresh as the last fetch. See
|
||
/// [_rolloverCheck].
|
||
final ServiceDayRollover _day = ServiceDayRollover();
|
||
|
||
/// Fires at the next local midnight. See [_armRollover].
|
||
Timer? _rollover;
|
||
|
||
/// Which trip tab is open.
|
||
int _selectedTripIndex = 0;
|
||
|
||
/// True once the rider has picked a tab himself. See [_settleTripTab].
|
||
bool _tripTabChosen = false;
|
||
|
||
/// Stops the rider has ticked, for "accept just these".
|
||
Set<String> _selectedStopIds = <String>{};
|
||
|
||
// ==================== LIFECYCLE METHODS ====================
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
BottomPage.currentTab.removeListener(_onTabChanged);
|
||
final keys = List<String>.from(_pickupTimers.keys);
|
||
for (final k in keys) {
|
||
_stopPickupPosting(k);
|
||
}
|
||
_pollerSubscription?.cancel();
|
||
_locationTimer?.cancel();
|
||
_rollover?.cancel();
|
||
_httpClient.close();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
_loadName();
|
||
_loadPickupsToday();
|
||
_ensureLocationPermission();
|
||
_ensureCameraPermission();
|
||
_startPolling();
|
||
_armRollover();
|
||
// What the counters are actually called, joined on the location id the
|
||
// booking already carries. One request, cached, and every failure resolves
|
||
// to "use the row's own name" — see [PickupLocations].
|
||
unawaited(PickupLocations.ensureLoaded());
|
||
// The building the logistics run is headed by — see [RiderHub]. Read from
|
||
// prefs the login wrote, so it costs nothing and cannot fail the fetch.
|
||
unawaited(RiderHub.ensureLoaded());
|
||
BottomPage.currentTab.addListener(_onTabChanged);
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_fetchQueues();
|
||
});
|
||
try {
|
||
if (Get.isRegistered<ProfileController>()) {
|
||
final pc = Get.find<ProfileController>();
|
||
ever(pc.userName, (_) {
|
||
_loadName();
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
@override
|
||
bool get wantKeepAlive => true;
|
||
|
||
// ==================== HELPER METHODS ====================
|
||
void _stopPickupPosting(String orderId) {
|
||
_pickupTimers[orderId]?.cancel();
|
||
_pickupTimers.remove(orderId);
|
||
}
|
||
|
||
String _getStatusFromOrder(Map<String, dynamic> Booking) {
|
||
final apiStatus = (Booking['orderstatus']?.toString().toLowerCase() ?? '')
|
||
.trim();
|
||
if (apiStatus == 'accepted') return 'ACCEPTED';
|
||
if (apiStatus == 'arrived') return 'ARRIVED';
|
||
if (apiStatus == 'picked') return 'PICKED';
|
||
return 'ACCEPT';
|
||
}
|
||
|
||
double _parseDouble(dynamic v) {
|
||
if (v == null) return 0.0;
|
||
if (v is num) return v.toDouble();
|
||
return double.tryParse(v.toString()) ?? 0.0;
|
||
}
|
||
|
||
String _getTimeGreeting() {
|
||
final hour = DateTime.now().hour;
|
||
if (hour < 12) return "Morning";
|
||
if (hour < 17) return "Afternoon";
|
||
return "Evening";
|
||
}
|
||
|
||
bool _hasActivePickup() {
|
||
return _activePickups.isNotEmpty;
|
||
}
|
||
|
||
// ==================== LOCATION METHODS ====================
|
||
/// The error radius, in metres, of the fix [_getValidCoordinates] last took.
|
||
/// Credited to the rider by [_isNearPickupLocation], the same way
|
||
/// `PickupsController._checkGeofence` credits its own.
|
||
double _lastFixAccuracy = 0;
|
||
|
||
/// ── `low` was fine for telemetry and is not fine for a 10 m fence ──
|
||
///
|
||
/// This asked for [LocationAccuracy.low] — a ~1 km hint on Android — and
|
||
/// fell back to a last-known position of any age. The pair it returns is
|
||
/// then used for two different jobs: it rides along on the status payload,
|
||
/// where a rough fix costs nothing, and it is measured against the geofence,
|
||
/// where a rough fix is not a measurement at all.
|
||
///
|
||
/// So it takes a real one while the fence is enforced, and keeps the cheap
|
||
/// path when it is not. A cached fix is accepted only if it is fresher than
|
||
/// [kGeofenceFixMaxAge] — on a round, a stale one is reliably the previous
|
||
/// stop.
|
||
Future<(String lat, String lng)?> _getValidCoordinates() async {
|
||
try {
|
||
await _ensureLocationPermission();
|
||
Position? pos;
|
||
try {
|
||
pos = await Geolocator.getCurrentPosition(
|
||
locationSettings: LocationSettings(
|
||
accuracy: kGeofenceEnforced
|
||
? LocationAccuracy.best
|
||
: LocationAccuracy.low,
|
||
timeLimit: Duration(seconds: kGeofenceEnforced ? 8 : 3),
|
||
),
|
||
);
|
||
} catch (_) {
|
||
final cached = await Geolocator.getLastKnownPosition();
|
||
final stale =
|
||
cached != null &&
|
||
kGeofenceEnforced &&
|
||
DateTime.now().difference(cached.timestamp) > kGeofenceFixMaxAge;
|
||
pos = stale ? null : cached;
|
||
}
|
||
_lastFixAccuracy = pos?.accuracy ?? 0;
|
||
if (pos != null) {
|
||
final lat = pos.latitude.toStringAsFixed(6);
|
||
final lng = pos.longitude.toStringAsFixed(6);
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString('last_lat', lat);
|
||
await prefs.setString('last_lng', lng);
|
||
} catch (_) {}
|
||
return (lat, lng);
|
||
}
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final lat = (prefs.getString('last_lat') ?? '').trim();
|
||
final lng = (prefs.getString('last_lng') ?? '').trim();
|
||
if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') {
|
||
return (lat, lng);
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> _ensureLocationPermission() async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) {
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null) {
|
||
await prefs.setString('last_lat', lastPos.latitude.toString());
|
||
await prefs.setString('last_lng', lastPos.longitude.toString());
|
||
}
|
||
Geolocator.openLocationSettings();
|
||
return;
|
||
}
|
||
|
||
LocationPermission permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
}
|
||
if (permission == LocationPermission.deniedForever ||
|
||
permission == LocationPermission.denied) {
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null) {
|
||
await prefs.setString('last_lat', lastPos.latitude.toString());
|
||
await prefs.setString('last_lng', lastPos.longitude.toString());
|
||
}
|
||
return;
|
||
}
|
||
|
||
Position? pos;
|
||
try {
|
||
pos = await Geolocator.getCurrentPosition(
|
||
desiredAccuracy: LocationAccuracy.medium,
|
||
timeLimit: const Duration(seconds: 5),
|
||
).timeout(const Duration(seconds: 5));
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Timeout getting location: $e');
|
||
pos = await Geolocator.getLastKnownPosition();
|
||
}
|
||
|
||
if (pos != null) {
|
||
await prefs.setString('last_lat', pos.latitude.toString());
|
||
await prefs.setString('last_lng', pos.longitude.toString());
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Error ensuring location permission: $e');
|
||
try {
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null) {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString('last_lat', lastPos.latitude.toString());
|
||
await prefs.setString('last_lng', lastPos.longitude.toString());
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
Future<void> _ensureCameraPermission() async {
|
||
try {
|
||
final status = await Permission.camera.status;
|
||
if (status.isDenied) {
|
||
await Permission.camera.request();
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Error ensuring camera permission: $e');
|
||
}
|
||
}
|
||
|
||
void _startLocationUpdates() {
|
||
_locationTimer?.cancel();
|
||
_locationTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||
_getAndUpdateCurrentLocation();
|
||
});
|
||
}
|
||
|
||
Future<void> _getAndUpdateCurrentLocation() async {
|
||
bool serviceEnabled;
|
||
LocationPermission permission;
|
||
|
||
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) {
|
||
_showLocationBottomSheet();
|
||
return;
|
||
}
|
||
|
||
permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (permission == LocationPermission.deniedForever) {
|
||
return;
|
||
}
|
||
|
||
Position position = await Geolocator.getCurrentPosition(
|
||
desiredAccuracy: LocationAccuracy.high,
|
||
);
|
||
|
||
if (_lastGeocodedPosition != null) {
|
||
final dist = Geolocator.distanceBetween(
|
||
_lastGeocodedPosition!.latitude,
|
||
_lastGeocodedPosition!.longitude,
|
||
position.latitude,
|
||
position.longitude,
|
||
);
|
||
if (dist < 100) return;
|
||
}
|
||
|
||
try {
|
||
List<Placemark> placemarks = await placemarkFromCoordinates(
|
||
position.latitude,
|
||
position.longitude,
|
||
);
|
||
if (placemarks.isNotEmpty) {
|
||
_lastGeocodedPosition = position;
|
||
Placemark place = placemarks.first;
|
||
String fullAddress =
|
||
"${place.name}, ${place.subLocality}, ${place.locality}, ${place.administrativeArea}, ${place.country}, ${place.postalCode}";
|
||
String city = place.locality ?? '';
|
||
String state = place.administrativeArea ?? '';
|
||
String suburb = place.subLocality ?? '';
|
||
if (mounted && city.isNotEmpty && city != _city) {
|
||
setState(() => _city = city);
|
||
}
|
||
await _updateProfile(
|
||
address: fullAddress,
|
||
latitude: position.latitude,
|
||
longitude: position.longitude,
|
||
city: city,
|
||
state: state,
|
||
suburb: suburb,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Error geocoding location: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> _updateProfile({
|
||
required String address,
|
||
required double latitude,
|
||
required double longitude,
|
||
required String city,
|
||
required String state,
|
||
required String suburb,
|
||
}) async {
|
||
debugPrint('Profile update → $address | $latitude,$longitude');
|
||
}
|
||
|
||
void _showLocationBottomSheet() {
|
||
// ── Through the sheet kit ──
|
||
//
|
||
// This drew a plain white sheet with its own 24 radius, an oversized 64pt
|
||
// icon in a filled disc, and a Close IconButton row — the one sheet in the
|
||
// app with an X, duplicating both the barrier tap and the drag the kit's
|
||
// handle advertises. The header idiom is the same one every other sheet
|
||
// now uses; the icon is the standard badge size, and the drag handle plus
|
||
// the barrier are how a sheet closes.
|
||
showMilerSheet<void>(
|
||
context,
|
||
builder: (ctx) => MilerSheetScaffold(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const MilerSheetHeader(
|
||
title: 'Turn on location',
|
||
subtitle:
|
||
'Bookings and live tracking need your position. Nothing is '
|
||
'assigned to a rider your office cannot see.',
|
||
icon: LucideIcons.mapPinOff,
|
||
),
|
||
SizedBox(height: 20.h),
|
||
MilerButton(
|
||
label: 'Enable location',
|
||
onPressed: () async {
|
||
Navigator.of(ctx).pop();
|
||
await Geolocator.openLocationSettings();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==================== PROXIMITY METHODS ====================
|
||
|
||
/// The fence's answer for the last stop [_isNearPickupLocation] measured.
|
||
///
|
||
/// Kept so the bulk-arrival sheet can name a distance instead of repeating a
|
||
/// generic "you are too far" for every ticked box.
|
||
GeofenceDecision? _lastBulkGeofence;
|
||
|
||
/// The bulk gate on Home: the *selected stops* route into the arrived rung.
|
||
///
|
||
/// ── One fence, reached two ways ──
|
||
///
|
||
/// `PickupsController._checkGeofence` guards a single stop; this guards the
|
||
/// "mark selected as arrived" action, and it runs before the controller is
|
||
/// ever called. The two used to be separate implementations with separate
|
||
/// radii — 500 m hardcoded here against the controller's configured 100 — so
|
||
/// which fence a rider met depended on whether he ticked boxes or slid a
|
||
/// sheet. Both now call [Geofence] and there is no arithmetic left in this
|
||
/// file to drift.
|
||
///
|
||
/// ── It used to fail open in four places ──
|
||
///
|
||
/// A booking with no pin, a rider fix that could not be read, a zero
|
||
/// coordinate pair and any thrown exception each ended in `return true`. All
|
||
/// four are refusals now; [Geofence] decides which sentence the rider sees.
|
||
Future<bool> _isNearPickupLocation(Map<String, dynamic> booking) async {
|
||
final decision = await Geofence.check(
|
||
targetLat: _parseDouble(booking['pickuplat'] ?? booking['PickupLat']),
|
||
targetLng: _parseDouble(booking['pickuplon'] ?? booking['PickupLon']),
|
||
action: 'Arrived',
|
||
);
|
||
_lastBulkGeofence = decision;
|
||
return decision.allowed;
|
||
}
|
||
|
||
/// How many bulk status writes may be in flight at once.
|
||
///
|
||
/// Not unbounded. Twenty simultaneous requests from one handset queue at the
|
||
/// radio on a rider's 3G connection and finish *slower* than a handful, and a
|
||
/// thundering herd from one rider is a poor shape to hand the backend. Six is
|
||
/// enough to hide the per-request latency — the thing that made the old
|
||
/// sequential loop take a minute — without any of that.
|
||
static const int _bulkLanes = 6;
|
||
|
||
/// Straight-line kilometres from the rider to a stop, measured on the device.
|
||
///
|
||
/// Handed to `updatePickedStatus` as `actualKms` purely so its priority-3
|
||
/// branch — an OSRM road-distance request per order, six-second timeout —
|
||
/// never runs during a bulk action. It is the same straight line that branch
|
||
/// already falls back to when the routing service fails.
|
||
double _bulkLegKm(
|
||
String riderLat,
|
||
String riderLng,
|
||
String stopLat,
|
||
String stopLng,
|
||
) {
|
||
final rLat = double.tryParse(riderLat) ?? 0;
|
||
final rLng = double.tryParse(riderLng) ?? 0;
|
||
final sLat = double.tryParse(stopLat) ?? 0;
|
||
final sLng = double.tryParse(stopLng) ?? 0;
|
||
if (rLat == 0 || rLng == 0 || sLat == 0 || sLng == 0) return 0;
|
||
return Geolocator.distanceBetween(rLat, rLng, sLat, sLng) / 1000.0;
|
||
}
|
||
|
||
Future<Map<String, bool>> _checkProximityForOrders(
|
||
List<String> orderIds,
|
||
) async {
|
||
final Map<String, bool> results = {};
|
||
for (final orderId in orderIds) {
|
||
final Booking = _ordersMap[orderId];
|
||
results[orderId] = Booking != null
|
||
? await _isNearPickupLocation(Booking)
|
||
: false;
|
||
}
|
||
return results;
|
||
}
|
||
|
||
Future<void> _showProximityWarning(
|
||
BuildContext context, {
|
||
String? specificMessage,
|
||
}) async {
|
||
return showAppDialog(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
builder: (ctx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
title: Row(
|
||
children: [
|
||
Icon(LucideIcons.mapPinOff, color: _primaryRed, size: 28),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
'Not at Pickup Location',
|
||
style: TextStyle(
|
||
fontSize: FontConstants.xLarge(context),
|
||
fontWeight: FontWeight.w700,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textPrimary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: Text(
|
||
specificMessage ??
|
||
'You must be within '
|
||
'${kGeofenceRadiusMetres.toStringAsFixed(0)} metres of the '
|
||
'pickup location to mark this booking as arrived.',
|
||
style: TextStyle(
|
||
fontSize: FontConstants.regular(context),
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
style: TextButton.styleFrom(foregroundColor: _primaryRed),
|
||
child: Text(
|
||
'OK',
|
||
style: TextStyle(
|
||
fontSize: FontConstants.regular(context),
|
||
fontWeight: FontWeight.w700,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==================== ONLINE/OFFLINE TOGGLE ====================
|
||
//
|
||
// The sheet itself is [DutySheet] — chrome, copy and the slide control. What
|
||
// stays here is the work: the pending-booking gate, the API call and the
|
||
// polling and tracking that follow it.
|
||
void _confirmOnlineOffline() {
|
||
if (_isToggling) return;
|
||
final bool newStatus = !isOnline;
|
||
if (!mounted) return;
|
||
|
||
DutySheet.show(
|
||
context,
|
||
goingOnline: newStatus,
|
||
onConfirm: () => _applyDutyChange(newStatus),
|
||
);
|
||
}
|
||
|
||
/// Carries out the duty change the sheet just asked for.
|
||
///
|
||
/// Returns true when duty actually changed — the sheet uses that to decide
|
||
/// whether to close or to spring its knob back and stay put.
|
||
Future<bool> _applyDutyChange(bool newStatus) async {
|
||
if (!mounted || _isToggling) return false;
|
||
|
||
// ── The one thing that can refuse ──
|
||
//
|
||
// A rider cannot walk away from work he is holding. This is checked here
|
||
// rather than by disabling the switch, because the answer depends on a
|
||
// stored flag that is cheaper to read on demand than to watch.
|
||
if (newStatus == false) {
|
||
bool hasPending =
|
||
_ordersMap.isNotEmpty || _pickupRunning.values.any((v) => v);
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
hasPending = hasPending || (prefs.getBool('has_live_pickup') ?? false);
|
||
} catch (_) {}
|
||
if (hasPending) {
|
||
if (!mounted) return false;
|
||
try {
|
||
await showAppDialog(
|
||
context: context,
|
||
builder: (dCtx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
title: Text(
|
||
'Finish your bookings first',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textPrimary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
content: Text(
|
||
'You are still holding work. Complete or hand back your open '
|
||
'bookings, then you can go off duty.',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(dCtx).pop(),
|
||
child: Text(
|
||
'OK',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _primaryRed,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
} catch (_) {}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!mounted) return false;
|
||
_isToggling = true;
|
||
showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (dialogCtx) => Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.all(22),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
child: SizedBox(
|
||
height: 40,
|
||
width: 40,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 3.2,
|
||
valueColor: AlwaysStoppedAnimation<Color>(_primaryRed),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
bool ok = false;
|
||
try {
|
||
ok = await _handleOnlineToggle(
|
||
newStatus,
|
||
).timeout(const Duration(seconds: 30));
|
||
} catch (_) {
|
||
ok = false;
|
||
} finally {
|
||
if (mounted && Navigator.of(context).canPop()) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
_isToggling = false;
|
||
}
|
||
|
||
if (!mounted) return false;
|
||
setState(() {
|
||
isOnline = ok ? newStatus : isOnline;
|
||
});
|
||
|
||
// Broadcast through the single source of truth so the pickups gate + every
|
||
// screen agree instantly.
|
||
try {
|
||
await DutyController.to.setOnlineLocal(isOnline);
|
||
} catch (_) {}
|
||
|
||
// ── Say what happened, not "Offline" ──
|
||
//
|
||
// This line was `successGlobal('Offline')`, unconditionally: it congratulated
|
||
// the rider on going offline when he had just come *on* duty, and said the
|
||
// same thing when the call had failed and his duty had not changed at all.
|
||
if (mounted) {
|
||
if (ok) {
|
||
AppFeedback.successGlobal(
|
||
newStatus ? "You're on duty" : "You're off duty",
|
||
);
|
||
} else {
|
||
AppFeedback.errorGlobal("Couldn't change your duty — try again");
|
||
}
|
||
}
|
||
|
||
if (ok && newStatus) {
|
||
_startLocationUpdates();
|
||
LiveTrackingService().startTracking();
|
||
_fetchQueues();
|
||
_startPolling();
|
||
} else if (ok && !newStatus) {
|
||
_locationTimer?.cancel();
|
||
LiveTrackingService().stopTracking();
|
||
_fetchQueues();
|
||
_startPolling();
|
||
}
|
||
|
||
return ok;
|
||
}
|
||
|
||
Future<bool> _handleOnlineToggle(bool online) async {
|
||
try {
|
||
if (!Get.isRegistered<RiderLogController>()) return false;
|
||
final ctl = Get.find<RiderLogController>();
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userId = prefs.getInt('userId') ?? prefs.getInt('userid');
|
||
if (userId == null) return false;
|
||
|
||
const String lat = '0';
|
||
const String lng = '0';
|
||
|
||
if (online) {
|
||
debugPrint('[BREAK] Ending break...');
|
||
final ok = await ctl
|
||
.endBreakAuto(latitude: lat, longitude: lng)
|
||
.timeout(const Duration(seconds: 12), onTimeout: () => false);
|
||
debugPrint('[BREAK] endBreakAuto -> $ok');
|
||
final dutyOk = await ctl.setOnDuty(true);
|
||
debugPrint('[ONDUTY] setOnDuty(true) -> $dutyOk');
|
||
await prefs.setBool('online', dutyOk);
|
||
} else {
|
||
debugPrint('[BREAK] Starting break...');
|
||
final ok = await ctl
|
||
.startBreakAuto(latitude: lat, longitude: lng)
|
||
.timeout(const Duration(seconds: 12), onTimeout: () => false);
|
||
debugPrint('[BREAK] startBreakAuto -> $ok');
|
||
final dutyOk = await ctl.setOnDuty(false);
|
||
debugPrint('[ONDUTY] setOnDuty(false) -> $dutyOk');
|
||
await prefs.setBool('online', dutyOk ? false : true);
|
||
}
|
||
return true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==================== DATA FETCHING ====================
|
||
Future<void> _loadName() async {
|
||
String name = '';
|
||
try {
|
||
if (Get.isRegistered<ProfileController>()) {
|
||
final pc = Get.find<ProfileController>();
|
||
final val = pc.userName.value;
|
||
if (val.toString().trim().isNotEmpty) {
|
||
name = val.toString().trim();
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (name.isEmpty) {
|
||
name = (prefs.getString('user_name') ?? '').trim();
|
||
}
|
||
_shiftStart = (prefs.getString('starttime') ?? '').toString();
|
||
_shiftEnd = (prefs.getString('endtime') ?? '').toString();
|
||
|
||
// Last fix written by _getValidCoordinates / the location loop — the
|
||
// origin for the route-length estimate in the header.
|
||
_riderLat =
|
||
_lastGeocodedPosition?.latitude ??
|
||
double.tryParse(prefs.getString('last_lat') ?? '');
|
||
_riderLng =
|
||
_lastGeocodedPosition?.longitude ??
|
||
double.tryParse(prefs.getString('last_lng') ?? '');
|
||
|
||
// Duty state now resolves through the single DutyController (correct
|
||
// priority lives there), so the gate agrees across every screen.
|
||
final bool online = await DutyController.to.load();
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_userName = name;
|
||
if (!_loadedOnlineFromPrefs) {
|
||
isOnline = online;
|
||
if (isOnline) {
|
||
try {
|
||
if (Get.isRegistered<LogController>()) {
|
||
Get.find<LogController>().startLogging();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
_loadedOnlineFromPrefs = true;
|
||
}
|
||
});
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_userName = name;
|
||
isOnline = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Loads today's completed-pickup count from the shared SummaryController
|
||
/// so the hero shows a real metric instead of a hardcoded placeholder.
|
||
Future<void> _loadPickupsToday() async {
|
||
try {
|
||
final controller = Get.isRegistered<SummaryController>()
|
||
? Get.find<SummaryController>()
|
||
: Get.put(SummaryController());
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
|
||
if (uid > 0) {
|
||
await controller.fetchSummaryStats(uid);
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _pickupsToday = controller.today.value);
|
||
} catch (_) {}
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
super.didChangeAppLifecycleState(state);
|
||
// Before the early return below: a resume inside the same foreground state
|
||
// still has to notice a midnight that passed while the phone was in the
|
||
// rider's pocket, and that is the case the timer cannot cover — both
|
||
// platforms suspend timers in the background.
|
||
if (state == AppLifecycleState.resumed) _rolloverCheck();
|
||
final bool foreground = state == AppLifecycleState.resumed;
|
||
if (foreground == _pollForeground) return;
|
||
_pollForeground = foreground;
|
||
if (foreground) {
|
||
// Catch up once on the way back in, then resume the normal cadence.
|
||
_fetchQueues();
|
||
_startPolling();
|
||
} else {
|
||
_startPolling();
|
||
}
|
||
}
|
||
|
||
/// Home's index in the bottom bar.
|
||
static const int _homeTabIndex = 0;
|
||
|
||
/// Re-reads the day the moment Home becomes the visible tab.
|
||
///
|
||
/// ── Why a 30-second poll was not good enough ──
|
||
///
|
||
/// Home polls on the shared interval — 30s waiting for the hub, 60s off duty
|
||
/// — which is right for work arriving *from* the hub and wrong for work the
|
||
/// rider just finished with his own hands on the next tab along. He
|
||
/// completed a delivery, watched Deliveries go 22 → 21, tapped Home, and the
|
||
/// pill said 22 for up to another half-minute.
|
||
///
|
||
/// Deliveries and Activity have both listened for this for a while
|
||
/// (`MyPickups._onTabChanged`, `ActivityPage`); Home was the one screen that
|
||
/// did not, which is why it was also the one screen showing a stale figure.
|
||
void _onTabChanged() {
|
||
if (BottomPage.currentTab.value == _homeTabIndex &&
|
||
mounted &&
|
||
!_isFetchingQueues) {
|
||
_fetchQueues();
|
||
}
|
||
}
|
||
|
||
void _startPolling() {
|
||
if (!_pollForeground) {
|
||
_pollerSubscription?.cancel();
|
||
_pollerSubscription = null;
|
||
_currentPollInterval = null;
|
||
return;
|
||
}
|
||
|
||
// 5s while a stop is live, 30s waiting for the hub, 60s off duty — the
|
||
// shared policy, so Home and Bookings cannot disagree about how hard they
|
||
// work the rider's battery. See [pollInterval].
|
||
final interval = pollInterval(
|
||
hasLiveWork: _activePickups.isNotEmpty,
|
||
onDuty: isOnline,
|
||
);
|
||
|
||
if (_pollerSubscription != null && _currentPollInterval == interval) {
|
||
return;
|
||
}
|
||
|
||
_pollerSubscription?.cancel();
|
||
_currentPollInterval = interval;
|
||
|
||
_pollerSubscription = Stream.periodic(interval, (_) {})
|
||
.asyncMap((_) async {
|
||
if (mounted && !_isFetchingQueues) {
|
||
await _fetchQueues();
|
||
}
|
||
})
|
||
.listen(
|
||
(_) {},
|
||
onError: (error) {
|
||
debugPrint('[HOMEPAGE][STREAM ERROR] $error');
|
||
},
|
||
cancelOnError: false,
|
||
);
|
||
}
|
||
|
||
/// Calls a stop's customer, and says so when the dialer will not open.
|
||
Future<void> _callStop(Map<String, dynamic> stop) async {
|
||
final phone = (stop['pickupcontactno'] ?? '').toString();
|
||
final ok = await launchPhoneDialer(phone);
|
||
if (!ok && mounted) {
|
||
AppFeedback.error(
|
||
context,
|
||
phone.trim().isEmpty
|
||
? 'No phone number on this stop'
|
||
: "Couldn't open the dialer — $phone",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Pull-to-refresh on Home.
|
||
///
|
||
/// Deliberately not `onRefresh: _fetchQueues`, for two reasons.
|
||
///
|
||
/// **The background poll gets in the way.** [_fetchQueues] self-guards with
|
||
/// [_isFetchingQueues] and returns *immediately* when a fetch is already
|
||
/// running — and with a 5s poll that is close to a coin flip on any given
|
||
/// pull. Handing that future straight to the indicator would dismiss the
|
||
/// spinner the moment the rider let go, about half the time, which looks
|
||
/// exactly like a refresh that failed. So a pull waits for the in-flight
|
||
/// fetch to land rather than racing it.
|
||
///
|
||
/// **An instant answer still has to look like an answer.** [_fetchQueues]
|
||
/// short-circuits when the payload is unchanged (`_lastQueuesJson`), so a
|
||
/// refresh can finish in a few milliseconds and the spinner would blink out
|
||
/// before it was ever read as spinning. The floor below is what every app the
|
||
/// rider already uses does here.
|
||
Future<void> _handleRefresh() async {
|
||
// Started first so the work runs *inside* the floor, not after it.
|
||
// 650 → 400: still long enough to read as a real refresh rather than a
|
||
// flicker, 250ms cheaper on every pull.
|
||
final minimumSpin = Future<void>.delayed(const Duration(milliseconds: 400));
|
||
|
||
try {
|
||
// Wait out a poll already in flight. Bounded, so a hung request cannot
|
||
// pin the spinner to the screen indefinitely.
|
||
final deadline = DateTime.now().add(const Duration(seconds: 6));
|
||
while (_isFetchingQueues && DateTime.now().isBefore(deadline)) {
|
||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||
}
|
||
|
||
// Name, city and the shift window come from the profile; the trips come
|
||
// from the queue. He pulled to re-read the screen, not half of it.
|
||
if (mounted) await _loadName();
|
||
if (mounted) await _fetchQueues();
|
||
} catch (e) {
|
||
// Never rethrow into the indicator: an error out of onRefresh becomes an
|
||
// unhandled framework exception, and the rider's recourse is to pull
|
||
// again either way. Both calls already log their own failures.
|
||
debugPrint('[REFRESH] Home refresh failed: $e');
|
||
}
|
||
|
||
await minimumSpin;
|
||
}
|
||
|
||
Future<void> _fetchQueues() async {
|
||
if (_isFetchingQueues) return;
|
||
|
||
try {
|
||
_isFetchingQueues = true;
|
||
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userId = prefs.getInt('userid');
|
||
|
||
if (userId == null) {
|
||
// Not signed in — there is no route to show. The debug branch that used
|
||
// to fill this with seeded demo trips is gone with the rest of the mock
|
||
// layer: Home is now a window onto the hub's answer, and an empty one
|
||
// means the hub said nothing.
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_ordersMap.clear();
|
||
_orderIds.clear();
|
||
_selectedOrders.clear();
|
||
isAllSelected = false;
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Keep the online/offline UI in sync for the empty-state nudge, but do NOT
|
||
// hard-block here. The backend owns assignment; hiding API-returned
|
||
// bookings behind a flaky local duty flag silently loses real work. The
|
||
// "on-duty to start a pickup" rule is enforced (loudly) at the action via
|
||
// ensureOnlineForAction — not by dropping data on the floor.
|
||
final bool online = await DutyController.to.load();
|
||
if (mounted && isOnline != online) {
|
||
setState(() => isOnline = online);
|
||
}
|
||
|
||
// Try fetching from API. On the live/new backend we show ONLY real data
|
||
// (an empty queue stays empty); mock data is used solely on the old demo
|
||
// path so testers still see something offline.
|
||
List<dynamic> items;
|
||
try {
|
||
items = await _pickupProvider.getPickupQueues();
|
||
} catch (e) {
|
||
debugPrint('[FETCH_QUEUES] API error: $e');
|
||
items = <dynamic>[];
|
||
}
|
||
|
||
if (!mounted) return;
|
||
|
||
await _fetchActivePickups(userId);
|
||
|
||
// Bookings the rider already accepted or rejected (persisted) must not
|
||
// reappear in the Home queue, even after a refetch or coming back to the
|
||
// Home tab.
|
||
final acceptedIds = await getAcceptedOrderIds();
|
||
final rejectedIds = await getRejectedOrderIds();
|
||
final collectedIds = await getCollectedOrderIds();
|
||
final pivotActions = await getPivotNextActions();
|
||
// Stops parked for a return visit. They are Activity's now — see the
|
||
// stamping loop below and `TripCard._showsOnHome`.
|
||
final skippedIds = await getSkippedOrderIds();
|
||
// Stops he has already closed today. Same argument as the skips: the
|
||
// rider's own record of what he did beats a queue that has not caught up.
|
||
final completedIds = await getCompletedOrderIds();
|
||
// Kept, because the pill's count needs the same answer the Deliveries
|
||
// tab builds its list from — see [_acceptedWaitingCount]. Assigned
|
||
// directly rather than through `setState`: this runs inside a fetch that
|
||
// already rebuilds, and the field is only ever read during that build.
|
||
_closedIds = {...completedIds, ...skippedIds};
|
||
|
||
// Accepted stops have left the Home list for the Bookings tab, but they
|
||
// are still on the rider's route — their parcels are still to be
|
||
// collected and their kilometres still to be ridden. The route brief
|
||
// must count them or it understates the day.
|
||
// Through the day filter as well. The accepted store is deliberately
|
||
// *not* pruned to today — it is "what have I taken on", and an id set
|
||
// that forgets a stop would flip that stop back to pending under the
|
||
// rider's thumb — but the route brief above the run is a statement about
|
||
// **this** day's kilometres and parcels. Yesterday's accepted-and-unclosed
|
||
// booking belongs in the set and not in that total.
|
||
//
|
||
// Only the rows are filtered; `acceptedIds` above is left whole.
|
||
final acceptedStops = _todayOnly(await getAcceptedBookings());
|
||
if (mounted && acceptedStops.length != _acceptedStops.length) {
|
||
setState(() => _acceptedStops = acceptedStops);
|
||
} else {
|
||
_acceptedStops = acceptedStops;
|
||
}
|
||
|
||
// EVERY stop the hub assigned today, whatever its state.
|
||
//
|
||
// Home used to drop a stop the moment it was accepted, which emptied the
|
||
// screen out as the rider worked and made it impossible to show trip
|
||
// progress. A trip is a unit: it stays on Home from assignment until the
|
||
// last stop is resolved, and each stop renders its own state (pending /
|
||
// accepted / active / done / rejected) via `stopStateOf`.
|
||
//
|
||
// Accepted stops still also appear on Bookings — that is where he WORKS
|
||
// them. Home is where he watches the trip fill up.
|
||
final allStops = _todayOnly(
|
||
items.whereType<Map<String, dynamic>>().toList(),
|
||
);
|
||
|
||
// ── The skip store is the truth about what is parked ──
|
||
//
|
||
// The queue endpoints are behind the rider by up to a poll, and on demo
|
||
// data they never catch up at all, so a raw `orderstatus` alone gets both
|
||
// directions of a skip wrong: a stop he has just skipped still reports as
|
||
// live, and one he has just resumed from Activity still reports as
|
||
// 'skipped' — and `stopStateOf` reads a raw 'skipped' ahead of any local
|
||
// decision, so the resume would silently undo itself within seconds.
|
||
//
|
||
// Stamped both ways here, from the same store Activity reads, so the
|
||
// progress rail and Activity can never disagree about which stops are
|
||
// parked.
|
||
// ── The rider's own arrivals ──
|
||
//
|
||
// Read alongside the other local sets and applied with them, because
|
||
// `reached` does not persist the rung: without this the queue reports
|
||
// `Miler_Assigned` for a stop the rider is standing at and the row drops
|
||
// back to ACCEPTED. See [getArrivedOrderIds] for the whole of it.
|
||
final arrivedIds = await getArrivedOrderIds();
|
||
|
||
for (final s in allStops) {
|
||
final id = (s['orderid'] ?? '').toString();
|
||
if (id.isEmpty) continue;
|
||
final raw = stopStatusFromRaw(s['orderstatus']);
|
||
// Finished beats everything: a stop he has closed is closed, whatever
|
||
// the queue still says about it. Without this the row he just picked
|
||
// up stays on Home wearing its LIVE mark until the backend catches up,
|
||
// which reads as the confirm not having worked.
|
||
if (completedIds.contains(id)) {
|
||
s['orderstatus'] = 'picked';
|
||
} else if (skippedIds.contains(id)) {
|
||
s['orderstatus'] = 'skipped';
|
||
} else if (raw == StopStatus.skipped && acceptedIds.contains(id)) {
|
||
s['orderstatus'] = 'accepted';
|
||
} else if (arrivedIds.contains(id) && _localArrivalMayShow(raw)) {
|
||
// Below the three above on purpose. Arrival is the *weakest* of the
|
||
// local records — it is the one the server could not confirm — so
|
||
// anything the server does know about this stop outranks it.
|
||
s['orderstatus'] = 'arrived';
|
||
}
|
||
}
|
||
|
||
// Still-undecided stops only — the accept/reject actions and the "N
|
||
// pending" counts operate on this narrower set.
|
||
final validOrders = allStops.where((m) {
|
||
final oid = (m['orderid'] ?? '').toString();
|
||
if (oid.isNotEmpty && _hiddenOrderIds.contains(oid)) return false;
|
||
if (oid.isNotEmpty && acceptedIds.contains(oid)) return false;
|
||
if (oid.isNotEmpty && rejectedIds.contains(oid)) return false;
|
||
final st = stopStatusFromRaw(m['orderstatus']);
|
||
return st != StopStatus.active &&
|
||
st != StopStatus.accepted &&
|
||
st != StopStatus.arrived &&
|
||
st != StopStatus.picked &&
|
||
st != StopStatus.cancelled &&
|
||
st != StopStatus.rejected &&
|
||
// A parked stop is not on offer. It was never rendered as a
|
||
// decision row, but it was still counted as one — so "3 stops to
|
||
// accept" included stops the rider had already put down.
|
||
st != StopStatus.skipped;
|
||
}).toList();
|
||
|
||
// Visibility into the pipeline so a "API returned N but Home shows 0" can
|
||
// be diagnosed from logs (online flag, raw count, and survivors).
|
||
debugPrint(
|
||
'[FETCH_QUEUES] line=${ServiceProfile.active.line.name} '
|
||
'online=$online rawFromApi=${items.length} '
|
||
'afterFilters=${validOrders.length} '
|
||
'hidden=${_hiddenOrderIds.length} accepted=${acceptedIds.length} '
|
||
'rejected=${rejectedIds.length}',
|
||
);
|
||
|
||
// Keyed on ALL stops plus the local decision sets: a stop moving from
|
||
// accepted to done changes no pending row, but it does move the trip's
|
||
// completion ring, so the card has to repaint.
|
||
final newJson = jsonEncode({
|
||
'all': allStops,
|
||
'accepted': acceptedIds.toList()..sort(),
|
||
'rejected': rejectedIds.toList()..sort(),
|
||
});
|
||
if (newJson == _lastQueuesJson) {
|
||
return;
|
||
}
|
||
_lastQueuesJson = newJson;
|
||
|
||
final Map<String, Map<String, dynamic>> newOrdersMap = {};
|
||
final List<String> newOrderIds = [];
|
||
|
||
for (final Booking in validOrders) {
|
||
final orderId = (Booking['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) continue;
|
||
newOrdersMap[orderId] = Booking;
|
||
newOrderIds.add(orderId);
|
||
}
|
||
|
||
final Map<String, bool> newSelection = {};
|
||
for (final orderId in newOrderIds) {
|
||
newSelection[orderId] = _selectedOrders[orderId] ?? false;
|
||
}
|
||
|
||
setState(() {
|
||
_allTripStops = allStops;
|
||
_acceptedIds = acceptedIds;
|
||
_rejectedIds = rejectedIds;
|
||
_collectedIds = collectedIds;
|
||
_pivotActions = pivotActions;
|
||
_ordersMap = newOrdersMap;
|
||
_orderIds = newOrderIds;
|
||
_selectedOrders = newSelection;
|
||
isAllSelected =
|
||
_selectedOrders.isNotEmpty &&
|
||
_selectedOrders.values.every((selected) => selected);
|
||
for (final orderId in _orderIds) {
|
||
if (!_pickupRunning.containsKey(orderId)) {
|
||
_pickupRunning[orderId] = false;
|
||
}
|
||
}
|
||
});
|
||
// After the state is applied, because the slots are derived from it.
|
||
_settleTripTab();
|
||
} catch (e) {
|
||
debugPrint('[FETCH_QUEUES] Error: $e');
|
||
if (mounted) setState(() => _fetchFailed = true);
|
||
} finally {
|
||
_isFetchingQueues = false;
|
||
if (!_queuesLoaded && mounted) {
|
||
setState(() => _queuesLoaded = true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Opens Home on a tab that has work on it.
|
||
///
|
||
/// ── Why this became necessary ──
|
||
///
|
||
/// The tab index is a **day part**, not a position: slot 0 is the morning
|
||
/// whether or not the rider has morning work. That is the right model, and
|
||
/// while nothing could place a trip by day part it cost nothing — every trip
|
||
/// fell into the undated bucket and landed on Trip 1, so Trip 1 was where the
|
||
/// work was.
|
||
///
|
||
/// Now that [Trip.dayPartTimeOf] can place them, a rider whose whole day is
|
||
/// an afternoon run has his work under Trip 2 — correctly — and Home opened
|
||
/// on Trip 1 and told him his hub had assigned him nothing. The fix for one
|
||
/// screen must not hand him a worse version of the same lie.
|
||
///
|
||
/// So the landing tab is the first slot that actually holds stops. Three
|
||
/// things keep it from fighting the rider:
|
||
///
|
||
/// • It gives up the moment he taps a tab himself — [_tripTabChosen]. An
|
||
/// empty Trip 3 he chose deliberately is an answer he asked for.
|
||
/// • It gives up the moment the tab it is on has work, so it lands once and
|
||
/// then stops moving under him.
|
||
/// • It never *hides* a slot. The tabs still show all three, empty ones
|
||
/// included, because "the day has room left in it" is a fact he plans
|
||
/// around.
|
||
void _settleTripTab() {
|
||
if (_tripTabChosen || !mounted) return;
|
||
final trips = _trips;
|
||
final here = trips.tripAt(_selectedTripIndex);
|
||
if (here != null && here.stops.isNotEmpty) return;
|
||
|
||
final slots = trips.slots;
|
||
for (var i = 0; i < slots.length; i++) {
|
||
final t = slots[i];
|
||
if (t == null || t.stops.isEmpty) continue;
|
||
if (i != _selectedTripIndex) {
|
||
setState(() {
|
||
_selectedTripIndex = i;
|
||
_selectedStopIds.clear();
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
/// ── Home is today's work, and only today's ──
|
||
///
|
||
/// The rule, and the reasoning behind every part of it, is
|
||
/// [ServiceDay.onlyToday] — shared with the Deliveries queue, because the two
|
||
/// screens showing the rider's live work must not disagree about which day it
|
||
/// is. What is local here is only *which* day: the one the screen is
|
||
/// currently showing, adopted so the midnight check below compares against
|
||
/// the pixels rather than reading the wall clock twice.
|
||
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
|
||
_day.rollIfNeeded();
|
||
return ServiceDay.onlyToday(stops, now: _day.day, where: 'HOME');
|
||
}
|
||
|
||
/// ── The day ending under a screen that is already open ──
|
||
///
|
||
/// [ServiceDay] answers "which day is this row" at read time, which is right
|
||
/// for the data and not sufficient for the screen: [_fetchQueues] filters
|
||
/// against the day *as read when it ran*. A rider who leaves Home open
|
||
/// through 23:59, or backgrounds the app on it and picks the phone up at six,
|
||
/// is looking at a route filtered against a day that has ended.
|
||
///
|
||
/// Two triggers, because a phone gives no single reliable one — a timer to
|
||
/// the next local midnight for the app left in the foreground, and resume for
|
||
/// every other case. Both land in [_rolloverCheck], which is idempotent.
|
||
/// The same arrangement `ActivityPage` uses, for the same reason.
|
||
void _armRollover() {
|
||
_rollover?.cancel();
|
||
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
|
||
}
|
||
|
||
/// Turns Home over when the service day has moved on.
|
||
///
|
||
/// Yesterday's stops are cleared **before** the refetch, not after it. A new
|
||
/// day starts empty even with no signal, because the alternative is a rider
|
||
/// in a dead spot at six in the morning reading yesterday's route as today's
|
||
/// assignment — the exact failure this exists to stop. Anything that really
|
||
/// is the new day's comes straight back from the fetch.
|
||
void _rolloverCheck() {
|
||
if (!mounted) return;
|
||
_armRollover();
|
||
if (!_day.rollIfNeeded()) return;
|
||
setState(() {
|
||
_allTripStops = <Map<String, dynamic>>[];
|
||
_ordersMap = <String, Map<String, dynamic>>{};
|
||
_orderIds = <String>[];
|
||
_selectedOrders = <String, bool>{};
|
||
_selectedStopIds = <String>{};
|
||
_acceptedStops = <Map<String, dynamic>>[];
|
||
isAllSelected = false;
|
||
// The fetch short-circuits on an unchanged payload, and the payload has
|
||
// not changed — only the day it is read against has. Clearing the key is
|
||
// what lets the new day's answer through.
|
||
_lastQueuesJson = '';
|
||
});
|
||
_fetchQueues();
|
||
}
|
||
|
||
/// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call
|
||
/// site reads the same as it did while the rule itself is reachable from a
|
||
/// test — a precedence contract that can only be exercised by building the
|
||
/// whole page is a contract nobody checks.
|
||
static bool _localArrivalMayShow(StopStatus serverSaid) =>
|
||
HomepageArrivalPrecedence.localArrivalMayShow(serverSaid);
|
||
|
||
/// Applies a set of stops to the trip state. Shared by the normal fetch and
|
||
/// the signed-out demo path.
|
||
void _applyStops(
|
||
List<Map<String, dynamic>> allStops,
|
||
Set<String> acceptedIds,
|
||
Set<String> rejectedIds,
|
||
) {
|
||
if (!mounted) return;
|
||
|
||
final Map<String, Map<String, dynamic>> ordersMap = {};
|
||
final List<String> orderIds = [];
|
||
for (final stop in allStops) {
|
||
final id = (stop['orderid'] ?? '').toString();
|
||
if (id.isEmpty) continue;
|
||
if (_hiddenOrderIds.contains(id)) continue;
|
||
if (acceptedIds.contains(id) || rejectedIds.contains(id)) continue;
|
||
final st = stopStatusFromRaw(stop['orderstatus']);
|
||
if (st == StopStatus.active ||
|
||
st == StopStatus.accepted ||
|
||
st == StopStatus.arrived ||
|
||
st == StopStatus.picked ||
|
||
st == StopStatus.cancelled ||
|
||
st == StopStatus.rejected ||
|
||
st == StopStatus.skipped) {
|
||
continue;
|
||
}
|
||
ordersMap[id] = stop;
|
||
orderIds.add(id);
|
||
}
|
||
|
||
setState(() {
|
||
_fetchFailed = false;
|
||
_allTripStops = allStops;
|
||
_acceptedIds = acceptedIds;
|
||
_rejectedIds = rejectedIds;
|
||
_ordersMap = ordersMap;
|
||
_orderIds = orderIds;
|
||
_selectedOrders = {for (final id in orderIds) id: false};
|
||
});
|
||
}
|
||
|
||
Future<void> _fetchActivePickups(int userId) async {
|
||
try {
|
||
List<dynamic> itemsV2 = [];
|
||
try {
|
||
itemsV2 = await _pickupProvider.getCurrentPickups();
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Error fetching active bookings: $e');
|
||
}
|
||
|
||
// A stop the rider has already closed is not active, whatever this
|
||
// endpoint still says. It lags the confirm by a poll or two, and since
|
||
// these get merged into the trip (see [_tripStops]) a stale row would
|
||
// reappear on Home wearing its LIVE mark seconds after he finished it.
|
||
final closedIds = <String>{
|
||
...await getCompletedOrderIds(),
|
||
...await getSkippedOrderIds(),
|
||
};
|
||
|
||
// ── Through the same day filter as the queue ──
|
||
//
|
||
// These are merged into the trip by [_tripStops], and a row this call
|
||
// carries that the queue does not is **appended** — so an unfinished
|
||
// stop still reporting `active` from a shift two days ago walked past
|
||
// [_todayOnly] and back onto Home through the side door, wearing a LIVE
|
||
// mark. One day, one rule, however the row arrived.
|
||
final activeOrders = _todayOnly(
|
||
itemsV2.whereType<Map<String, dynamic>>().where((booking) {
|
||
final status =
|
||
(booking['orderstatus']?.toString().toLowerCase() ?? '').trim();
|
||
if (status != 'active') return false;
|
||
return !closedIds.contains((booking['orderid'] ?? '').toString());
|
||
}).toList(),
|
||
);
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_activePickups = activeOrders;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[HOMEPAGE] Error fetching active pickup: $e');
|
||
}
|
||
}
|
||
|
||
/// Opens the map screen for the stop the rider is on, from its row on Home.
|
||
///
|
||
/// This used to be `_navigateToActivePickup`, which — despite the name —
|
||
/// navigated nowhere: it waited half a second and refetched the queue. The
|
||
/// banner that called it was therefore a red strip across the bottom of the
|
||
/// screen whose only visible effect on tap was a spinner. Bookings has had
|
||
/// the real entry point all along; Home just never used it.
|
||
Future<void> _continueStop(Map<String, dynamic> stop) async {
|
||
if (!mounted) return;
|
||
debugPrint('[HOMEPAGE] Continue active stop: ${stop['orderid']}');
|
||
await pickups.MyPickups.navigateToPickupMap(context, stop);
|
||
if (!mounted) return;
|
||
await _fetchQueues();
|
||
}
|
||
|
||
// ==================== PICKUP LOG METHODS ====================
|
||
void _startPickupPosting(Map<String, dynamic> it) async {
|
||
final orderId = (it['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) return;
|
||
|
||
final base = <String, dynamic>{
|
||
'logid': 0,
|
||
'tenantid': it['tenantid'] ?? 0,
|
||
'partnerid': it['partnerid'] ?? 0,
|
||
'locationid': it['locationid'] ?? 0,
|
||
'orderheaderid': it['orderheaderid'] ?? 0,
|
||
'pickupid': it['pickupid'] ?? 0,
|
||
'userid': it['userid'] ?? 0,
|
||
'orderid': orderId,
|
||
'orderstatus': 'picked',
|
||
};
|
||
_pickupBasePayload[orderId] = base;
|
||
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_tenantid',
|
||
(base['tenantid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_partnerid',
|
||
(base['partnerid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_locationid',
|
||
(base['locationid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_orderheaderid',
|
||
(base['orderheaderid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_pickupid',
|
||
(base['pickupid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_userid',
|
||
(base['userid'] ?? 0).toString(),
|
||
);
|
||
await prefs.setString('pickuplog_${orderId}_orderid', orderId);
|
||
await prefs.setString(
|
||
'pickuplog_${orderId}_orderstatus',
|
||
(base['orderstatus'] ?? 'picked').toString(),
|
||
);
|
||
} catch (_) {}
|
||
|
||
await _postPickupLog(orderId);
|
||
final interval = await _getLogInterval();
|
||
_pickupTimers[orderId]?.cancel();
|
||
_pickupTimers[orderId] = Timer.periodic(interval, (_) {
|
||
_postPickupLog(orderId);
|
||
});
|
||
}
|
||
|
||
Future<void> _postPickupLog(String orderId) async {
|
||
try {
|
||
Map<String, dynamic>? base = _pickupBasePayload[orderId];
|
||
if (base == null) {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
base = {
|
||
'logid': 0,
|
||
'tenantid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_tenantid') ?? '0',
|
||
) ??
|
||
0,
|
||
'partnerid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_partnerid') ?? '0',
|
||
) ??
|
||
0,
|
||
'locationid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_locationid') ?? '0',
|
||
) ??
|
||
0,
|
||
'orderheaderid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0',
|
||
) ??
|
||
0,
|
||
'pickupid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_pickupid') ?? '0',
|
||
) ??
|
||
0,
|
||
'userid':
|
||
int.tryParse(
|
||
prefs.getString('pickuplog_${orderId}_userid') ?? '0',
|
||
) ??
|
||
0,
|
||
'orderid':
|
||
prefs.getString('pickuplog_${orderId}_orderid') ?? orderId,
|
||
'orderstatus':
|
||
prefs.getString('pickuplog_${orderId}_orderstatus') ?? 'picked',
|
||
};
|
||
} catch (_) {}
|
||
}
|
||
if (base == null) return;
|
||
|
||
final coords = await _getValidCoordinates();
|
||
final now = DateTime.now();
|
||
final logdate =
|
||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||
|
||
final payload = {
|
||
...base,
|
||
'logdate': logdate,
|
||
'latitude': (coords?.$1 ?? '0'),
|
||
'longitude': (coords?.$2 ?? '0'),
|
||
};
|
||
|
||
await _pickupLogProvider.createPickupLog(payload);
|
||
} catch (e) {
|
||
debugPrint('[COLLECTIONLOG][POST] error: $e');
|
||
}
|
||
}
|
||
|
||
Future<Duration> _getLogInterval() async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
// Already correct — routed through the shared resolver so the 30 lives in
|
||
// one place. See [kDefaultLogSeconds] for what a zero here used to cost
|
||
// the rider-log loop, which read the same pref and did not guard it.
|
||
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
|
||
return Duration(seconds: interval);
|
||
} catch (_) {
|
||
return const Duration(seconds: kDefaultLogSeconds);
|
||
}
|
||
}
|
||
|
||
// ==================== BULK ACTION ====================
|
||
String _getBulkButtonLabel() {
|
||
final selectedOrderIds = _selectedOrders.entries
|
||
.where((e) => e.value)
|
||
.map((e) => e.key)
|
||
.toList();
|
||
|
||
if (selectedOrderIds.isEmpty) return "Accept Bookings";
|
||
|
||
final statuses = selectedOrderIds
|
||
.map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {}))
|
||
.toSet();
|
||
|
||
if (statuses.length == 1 && statuses.first == 'ACCEPT') {
|
||
return "Accept Bookings";
|
||
}
|
||
if (statuses.length == 1 && statuses.first == 'ACCEPTED') {
|
||
return "Move to Arrived";
|
||
}
|
||
if (statuses.length == 1 && statuses.first == 'ARRIVED') {
|
||
return "Move to Picked";
|
||
}
|
||
if (statuses.contains('ACCEPT')) {
|
||
return "Accept Bookings";
|
||
} else if (statuses.contains('ACCEPTED')) {
|
||
return "Move to Arrived";
|
||
} else {
|
||
return "Move to Picked";
|
||
}
|
||
}
|
||
|
||
/// Today's stops, grouped into the trips the hub manager actually assigned.
|
||
///
|
||
// ── Why [_allTripStops] is NOT filtered through [WorkBoundary] ──
|
||
//
|
||
// It was, briefly, and it broke two things at once — worth writing down so
|
||
// nobody re-applies it.
|
||
//
|
||
// `WorkBoundary` answers "which screen **works** this order?". Home answers a
|
||
// different question: "what is my day?". Those are not the same list.
|
||
//
|
||
// • `pickupQueue` drops `WorkDomain.closed`, and this list is documented to
|
||
// keep finished stops — the trip's progress ring needs them as its
|
||
// denominator. Filtering them turned "3 of 8 done" into "3 of 3 done".
|
||
// • On logistics the boundary is *acceptance*, so every accepted booking is
|
||
// `WorkDomain.delivery`. Home still has to show them: they are the rider's
|
||
// run, and the kitchen group expands to list exactly those orders. The
|
||
// filter emptied the group and the dropdown stopped opening.
|
||
//
|
||
// The guarantee the boundary exists for is about **actions**, not about
|
||
// display: Deliveries lists only what `deliveryQueue` admits, and Home's own
|
||
// rungs are gated by `MilkRun.worksOnOwnScreen`. An order can be *visible* on
|
||
// Home as part of the day while being *worked* on Deliveries — what it can
|
||
// never be is actionable in two places.
|
||
|
||
/// Built from [_allTripStops] — every stop in any state, finished ones
|
||
/// included. The finished stops are hidden from the *list* rather than
|
||
/// dropped from the trip (see `TripCard._sequence`), because the trip's
|
||
/// figures are computed from it: strip them here and "2 of 6 stops done"
|
||
/// becomes "0 of 4", and the ring empties itself every time the rider
|
||
/// finishes something.
|
||
List<Trip> get _trips => Trip.groupIntoTrips(
|
||
_tripStops,
|
||
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
hubLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// How much work is waiting on the **other** tab.
|
||
///
|
||
/// The number behind the floating pill, whose tap goes straight to
|
||
/// Deliveries — so it has to be that tab's own count, or the rider is sent to
|
||
/// a screen holding a different number of rows than the badge he pressed.
|
||
///
|
||
/// That is exactly what it used to be: `accepted + collected` over Home's own
|
||
/// view, which on a kitchen line counted a stop from the moment he accepted
|
||
/// it — an hour before anything was in his hands and before Deliveries would
|
||
/// list it at all. The pill said 6, the tab showed 0, and neither was wrong
|
||
/// about its own question.
|
||
///
|
||
/// Asked of [WorkBoundary] now, from the same day and the same sets the other
|
||
/// screen fetches with, so the two cannot disagree.
|
||
int get _acceptedWaitingCount => WorkBoundary.deliveryQueue(
|
||
_tripStops,
|
||
collectedIds: _collectedIds,
|
||
acceptedIds: _acceptedIds,
|
||
pivotActions: _pivotActions,
|
||
// ── The set the other screen was passing and this one was not ──
|
||
//
|
||
// Both screens call [WorkBoundary.deliveryQueue], which was supposed to be
|
||
// what stopped them disagreeing. It cannot do that when they hand it
|
||
// different arguments: Deliveries passed `closedIds`, Home did not, and a
|
||
// delivered stop therefore left one list and stayed in the other's count.
|
||
// The rider finished a drop, watched the tab go 22 → 21, and came back to
|
||
// Home still being told he had 22 in hand.
|
||
//
|
||
// It is worse than a stale figure here, because Home *stamps* a closed
|
||
// stop `picked` (see the local-records loop in `_fetchBookings`) so its
|
||
// row cannot flicker back to LIVE while the queue catches up. `picked` is
|
||
// squarely inside the delivery domain, so the pill was counting the stamp
|
||
// that was put there to hide it.
|
||
closedIds: _closedIds,
|
||
).length;
|
||
|
||
/// ── Today's work, as one run ──
|
||
///
|
||
/// Every stop the rider holds today, slot boundaries collapsed. This is what
|
||
/// the *counters* are derived from — the accepted pill has to mean "you are
|
||
/// holding this much", which is a fact about the day and not about whichever
|
||
/// tab happens to be on screen.
|
||
///
|
||
/// The screen itself is per-trip: the hub assigns work in slots, dispatch
|
||
/// rings the rider about "the second trip", and [_trips] is that grouping. See
|
||
/// [_selectedTrip] and the [TripSelector] in the header.
|
||
Trip get _todayTrip => Trip.fromStops(
|
||
id: 'today',
|
||
stops: _tripStops,
|
||
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
hubLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// The trip the tab bar currently points at, or null when that slot has not
|
||
/// been assigned yet.
|
||
///
|
||
/// Null is a real state, not an error: the tabs deliberately show Trip 2 and
|
||
/// Trip 3 before the hub has filled them, so the rider can see that the day
|
||
/// has room left in it. That case renders [_buildTripEmptyState], not the
|
||
/// whole-day empty state — "nothing in this slot yet" and "you have no work"
|
||
/// are different sentences and only one of them is true while Trip 1 runs.
|
||
Trip? get _selectedTrip {
|
||
final trips = _trips;
|
||
// Slot, not position — see [TripSlots.tripAt]. Slot 1 is the afternoon
|
||
// whether or not there is morning work, so an empty slot is "nothing
|
||
// assigned for that part of the day", not "past the end of the list".
|
||
if (_selectedTripIndex < 0) return null;
|
||
return trips.tripAt(_selectedTripIndex);
|
||
}
|
||
|
||
/// [_allTripStops] plus whatever the rider is actually on.
|
||
///
|
||
/// The two lists come from different endpoints — the queue, and the
|
||
/// current-pickups call behind [_fetchActivePickups] — and the queue does not
|
||
/// always still carry a stop once it has gone active. That was survivable
|
||
/// while the live stop was announced by its own floating banner; now that the
|
||
/// row *is* the announcement, a stop missing from the trip would mean a rider
|
||
/// with a job running and nothing on Home saying so.
|
||
///
|
||
/// Merged by order id, live copy last so it wins on status.
|
||
List<Map<String, dynamic>> get _tripStops {
|
||
if (_activePickups.isEmpty) return _allTripStops;
|
||
final live = <String, Map<String, dynamic>>{
|
||
for (final s in _activePickups)
|
||
if ((s['orderid'] ?? '').toString().isNotEmpty)
|
||
(s['orderid'] ?? '').toString(): s,
|
||
};
|
||
if (live.isEmpty) return _allTripStops;
|
||
|
||
final merged = <Map<String, dynamic>>[];
|
||
for (final s in _allTripStops) {
|
||
final id = (s['orderid'] ?? '').toString();
|
||
merged.add(live.remove(id) ?? s);
|
||
}
|
||
merged.addAll(live.values);
|
||
return merged;
|
||
}
|
||
|
||
/// Ticked stops that are still *this* trip's and still undecided.
|
||
///
|
||
/// Deliberately not just `_selectedStopIds`. That set can outlive the thing
|
||
/// it points at in two ways, and the floating [SelectionBar] would report
|
||
/// both as work waiting to be accepted:
|
||
///
|
||
/// • **A stop stops being pending.** Accepting or rejecting one on its own
|
||
/// row leaves the tick behind, so the count would keep including a stop the
|
||
/// rider has already decided.
|
||
/// • **The rider changes tab.** Selection belongs to one trip; a tick made on
|
||
/// Trip 1 must not be counted while Trip 2 is on screen — see the
|
||
/// `_selectedStopIds` reset in the tab's `onSelect`.
|
||
///
|
||
/// This mirrors exactly what the trip footer computes for its select-all row,
|
||
/// so the bar and the row can never disagree about the number.
|
||
Iterable<String> _selectedPendingIds() =>
|
||
(_selectedTrip?.pendingOrderIds(
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
) ??
|
||
const <String>[])
|
||
.where(_selectedStopIds.contains);
|
||
|
||
/// How many ticks the bar is acting on.
|
||
///
|
||
/// Pending-only for a parcel route, where ticking exists solely to choose
|
||
/// what to accept. A service rider keeps his selection through *arrived* and
|
||
/// *picked up*, so counting only undecided stops would hide the bar the
|
||
/// moment he accepted the very set he is about to walk into a kitchen with.
|
||
int get _selectionCount => ServiceProfile.active.handsOffAtCollection
|
||
? _selectedStopIds.where((id) => _stopById(id) != null).length
|
||
: _selectedPendingIds().length;
|
||
|
||
/// Accepts exactly the stops the rider ticked.
|
||
///
|
||
/// The middle ground between one-at-a-time and all-at-once: on a long trip
|
||
/// he can scan the list, tick the four addresses he knows are open, and take
|
||
/// them in a single action instead of eight taps.
|
||
/// ── Both filters here were keyed on the undecided list ──
|
||
///
|
||
/// `_ordersMap.containsKey` dropped every already-accepted id before the
|
||
/// action ran, and `_selectedOrders` was then rebuilt over `_orderIds` —
|
||
/// also undecided-only — so an accepted id could not survive into
|
||
/// [_handleBulkAction] by either route. The selection bar could accept and
|
||
/// nothing else, whatever its label said.
|
||
///
|
||
/// Both now resolve through [_bulkStopFor], so a ticked set that has already
|
||
/// been accepted arrives intact and the action advances it to arrived, and
|
||
/// then to picked. Which rung it is remains [_handleBulkAction]'s decision,
|
||
/// read off the stops themselves.
|
||
Future<void> _acceptSelectedStops(List<String> orderIds) async {
|
||
final ids = orderIds.where((id) => _bulkStopFor(id) != null).toList();
|
||
if (ids.isEmpty || _acceptingTripId != null) return;
|
||
|
||
setState(() {
|
||
_acceptingTripId = 'selection';
|
||
_selectedOrders = {for (final id in ids) id: true};
|
||
});
|
||
|
||
try {
|
||
await _handleBulkAction();
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_acceptingTripId = null;
|
||
_selectedStopIds = {..._selectedStopIds}..removeAll(ids);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sends the crate photo for a kitchen load. Never throws at the caller.
|
||
///
|
||
/// A failed upload costs the hub a picture; a failed upload that surfaced as
|
||
/// an error at a counter would cost the rider his place in the queue. So it
|
||
/// is logged and dropped — the load itself is already recorded, and that is
|
||
/// the part the run depends on.
|
||
Future<void> _uploadLoadProof(String path, String kitchenName) async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userId = prefs.getInt('userid') ?? 0;
|
||
if (userId <= 0) return;
|
||
final url = await Get.find<PickupsController>().uploadProofImage(
|
||
File(path),
|
||
'picked',
|
||
userId,
|
||
0,
|
||
);
|
||
debugPrint('[LOAD] $kitchenName crate photo → ${url ?? 'not stored'}');
|
||
} catch (e) {
|
||
debugPrint('[LOAD] crate photo upload failed: $e');
|
||
}
|
||
}
|
||
|
||
/// Moves one stop up the ladder from its row on Home: `ARRIVED`, then
|
||
/// `PICKED`.
|
||
///
|
||
/// ── Where the two rungs actually happen ──
|
||
///
|
||
/// On a parcel route both are at the customer's door and belong to Bookings.
|
||
/// On a service run they are at the **kitchen**: the rider arrives, is handed
|
||
/// the food, and only then does the order turn into a delivery. So the stop
|
||
/// stays on Home for both, and this is what walks it through them.
|
||
///
|
||
/// `PICKED` is the hand-off. It is written to the server — the parcel really
|
||
/// has been collected, which is exactly what the status means — and recorded
|
||
/// locally as collected, which is what moves the card to Bookings.
|
||
/// Advances one stop a rung.
|
||
///
|
||
/// Returns null when the rung was taken, and **the reason** when the server
|
||
/// refused it — see the PICKED branch below. It used to return `void`, so a
|
||
/// refused pivot left the stop on Home with nothing on screen to say why and
|
||
/// the confirmation sheet reporting success over the top of it.
|
||
Future<String?> _advanceStop(
|
||
Map<String, dynamic> stop,
|
||
String nextStatus,
|
||
) async {
|
||
final orderId = (stop['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty) return null;
|
||
|
||
try {
|
||
final dc = Get.find<PickupsController>();
|
||
final pickupId = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0;
|
||
final orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0;
|
||
final pickupLocationId =
|
||
int.tryParse('${stop['pickuplocationid'] ?? 0}') ?? 0;
|
||
final riderLoc = await _getValidCoordinates();
|
||
final pickupLat = _parseDouble(
|
||
stop['pickuplat'] ?? stop['PickupLat'] ?? 0,
|
||
).toString();
|
||
final pickupLng = _parseDouble(
|
||
stop['pickuplon'] ?? stop['PickupLon'] ?? 0,
|
||
).toString();
|
||
|
||
bool ok = false;
|
||
if (nextStatus == 'ARRIVED') {
|
||
ok = await dc.updateArrivedStatus(
|
||
pickupId: pickupId,
|
||
orderHeaderId: orderHeaderId,
|
||
ridersLat: riderLoc?.$1 ?? '0',
|
||
ridersLng: riderLoc?.$2 ?? '0',
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
);
|
||
} else {
|
||
ok = await dc.updatePickedStatus(
|
||
pickupId: pickupId,
|
||
orderId: orderId,
|
||
orderHeaderId: orderHeaderId,
|
||
pickupLocationId: pickupLocationId,
|
||
ridersLat: riderLoc?.$1 ?? '0',
|
||
ridersLng: riderLoc?.$2 ?? '0',
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
notes: '',
|
||
);
|
||
}
|
||
|
||
// ── A failed write moves nothing ──
|
||
//
|
||
// Every other status write on this screen is optimistic, and for arrival
|
||
// that is right: the rider is standing at a counter, the row is his own
|
||
// report of where he is, and snapping it back because a poll is late
|
||
// reads as the button having done nothing.
|
||
//
|
||
// Pickup-complete is not that kind of write. It is the boundary between
|
||
// the two halves of the app — see [WorkBoundary] — and crossing it on an
|
||
// unconfirmed call is how an order ends up on Deliveries while the
|
||
// backend still has it assigned for collection, which no refresh can
|
||
// repair because the local record outranks the queue by design. So the
|
||
// hand-over waits for the server, and a failure leaves the stop exactly
|
||
// where it is, on Home, still to be picked up.
|
||
if (!ok) {
|
||
debugPrint('[ADVANCE] $orderId -> $nextStatus reported failure');
|
||
// Handing the reason back rather than dropping it: a rider whose
|
||
// pickup the hub refused has to be told, or the only thing he can do
|
||
// is slide again at a stop that will keep refusing. See
|
||
// [PickupsController.lastPickupRefusal].
|
||
if (nextStatus == 'PICKED') return dc.lastPickupRefusal ?? 'refused';
|
||
|
||
// ── Optimistic covers a write that did not LAND, never a rider who is
|
||
// not THERE ──
|
||
//
|
||
// The paragraph above is right about the case it describes: a rider at
|
||
// a counter with no signal must not have his own report of where he is
|
||
// snapped back by a late poll.
|
||
//
|
||
// But `updateArrivedStatus` answers false for three different things,
|
||
// and only one of them is that case. It is also false when the geofence
|
||
// refused — the rider is 1.4km from the door and no request was ever
|
||
// made — and when the server itself rejected the arrival on a business
|
||
// rule. Advancing on those wrote ARRIVED onto a stop the rider had not
|
||
// reached, and the admin console, which only ever hears what the server
|
||
// was told, went on showing the stop as pending. That is the divergence
|
||
// reported from the field: arrived on the phone, not arrived in the
|
||
// office, and no way for either side to tell who was wrong.
|
||
//
|
||
// So the two "he is not there / the office said no" cases stop here and
|
||
// are handed back to be shown. A dead network still falls through and
|
||
// still advances — with [PickupsController.lastArrivalNotice] telling
|
||
// him the office has not been told.
|
||
//
|
||
// `lastBlockedReason` is read first and is always fresh: `_checkGeofence`
|
||
// clears it on entry, so a non-null value can only have come from the
|
||
// check that just ran.
|
||
final notThere = dc.lastBlockedReason ?? dc.lastArrivalRefusal;
|
||
if (notThere != null) return notThere;
|
||
}
|
||
|
||
// ── The rung the SERVER produced, not the one the button is named after ──
|
||
//
|
||
// `picked` was hardcoded here. In compatibility mode `pickup-complete`
|
||
// releases the consignment in the same call, so the server's answer is
|
||
// `Out_for_Delivery` — and stamping `picked` over it put the rider's
|
||
// card one rung behind his own consignment and behind the console, which
|
||
// reads the same state and calls it Active. Two screens, two truths, one
|
||
// stop.
|
||
//
|
||
// `lastPivotConsignmentStatus` is whatever the pivot actually reported.
|
||
// Absent (an older backend, a response that named nothing) it falls back
|
||
// to `picked`, which is the honest reading of "collected, nothing said
|
||
// about release".
|
||
if (nextStatus == 'ARRIVED') {
|
||
stop['orderstatus'] = 'arrived';
|
||
// ── And it has to survive the refresh this sheet is about to run ──
|
||
//
|
||
// The line above is a field on an in-memory row. `reached` does not
|
||
// persist the rung (request 15), so the `_fetchQueues()` at the foot of
|
||
// `_advanceStopWithSheet` rebuilt this row from a server that still
|
||
// says `Miler_Assigned` and put it straight back on ACCEPTED — about a
|
||
// second after the rider slid the sheet. See [getArrivedOrderIds].
|
||
await addArrivedOrderIds([orderId]);
|
||
} else {
|
||
// ── The rung is PICKED, whatever the pivot released ──
|
||
//
|
||
// This used to take the rung from `lastPivotConsignmentStatus`, so in
|
||
// compatibility mode — `MILER_COLLECTED_STATE_ENABLED` off, where
|
||
// `pickup-complete` releases the consignment itself — a collection
|
||
// landed on `Out_for_Delivery` and the rider's card jumped from
|
||
// ACCEPTED straight to ACTIVE. He never saw PICKED, and the round he
|
||
// had not started yet was already on the road.
|
||
//
|
||
// Which state the *server* is in is still recorded on the row, because
|
||
// `deliver` is gated on it and lying about it is how a hand-over gets
|
||
// refused at a door. What the rider is shown is his own ladder:
|
||
// collected here, released when he presses **Start delivery**. The two
|
||
// are allowed to differ — see `MyPickups._needsRelease`, which is the
|
||
// other half of this and the reason the difference is safe.
|
||
final produced = ConsignmentState.values
|
||
.where((s) => s.name == dc.lastPivotConsignmentStatus)
|
||
.firstOrNull;
|
||
stop['orderstatus'] = 'picked';
|
||
if (produced != null) stop['consignmentstatus'] = produced.name;
|
||
// Off the arrived rung the moment it is collected, so the local
|
||
// record can never outlive the stop it describes.
|
||
await removeArrivedOrderIds([orderId]);
|
||
}
|
||
|
||
// Written at the one moment it is true. Nothing else records these two:
|
||
// the backend keeps a booking's *current* status and returns no event
|
||
// feed, so without this the rider's own history of his morning is three
|
||
// rungs long. See [OrderEvent].
|
||
await stampOrderEvent(
|
||
orderId,
|
||
nextStatus == 'ARRIVED'
|
||
? OrderEvent.arrivedAtPickup
|
||
: OrderEvent.pickedUp,
|
||
);
|
||
|
||
if (nextStatus == 'PICKED') {
|
||
// Confirmed by the backend, so it is safe to record: this is the write
|
||
// that hands the order over to Deliveries.
|
||
await addCollectedOrderIds([orderId]);
|
||
await addAcceptedBookings([stop]);
|
||
// One copy of the day, and it is now stale — the other screen must not
|
||
// be able to render this order from a cached response that predates the
|
||
// hand-over. See [WorkRepository].
|
||
unawaited(WorkRepository.instance.invalidate());
|
||
if (!mounted) return null;
|
||
setState(() => _collectedIds = {..._collectedIds, orderId});
|
||
}
|
||
|
||
// ── When the hub did not hear it, say so ──
|
||
//
|
||
// Once, plainly, and without blocking anything. `reached` currently
|
||
// answers 200 and writes nothing (see
|
||
// [PickupsController.updateArrivedStatus]), so the rider's ARRIVED is
|
||
// his own record and not a shared one — and the first person to find
|
||
// that out should not be an operator wondering why a rider has been at a
|
||
// kitchen for forty minutes.
|
||
final notice = dc.lastArrivalNotice;
|
||
if (nextStatus == 'ARRIVED' && notice != null && mounted) {
|
||
AppFeedback.infoGlobal(notice);
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e');
|
||
return nextStatus == 'PICKED'
|
||
? 'Could not record this pickup — check your connection and try '
|
||
'again.'
|
||
: null;
|
||
}
|
||
}
|
||
|
||
/// Walks one stop up its next rung, behind a slide-to-confirm sheet.
|
||
///
|
||
/// ── Why the whole kitchen goes together ──
|
||
///
|
||
/// He arrived at a counter, not at an order. Confirming arrival for one bag
|
||
/// and leaving its two siblings on `accepted` would post three different
|
||
/// states for one event and leave him sliding the same sheet three times at
|
||
/// the same window. So the sheet acts on every stop from that source that is
|
||
/// on the same rung — which is exactly the set the batch bar would have
|
||
/// acted on, reached without having to tick anything.
|
||
///
|
||
/// Collection is different and stays per bag on the group page; this is the
|
||
/// arrival, which is genuinely one event.
|
||
Future<void> _advanceStopWithSheet(Map<String, dynamic> stop) async {
|
||
final state = stopStateOf(
|
||
stop,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
);
|
||
final rung = switch (state) {
|
||
StopState.accepted => StopStatus.accepted,
|
||
// Both, because this answers *which rung is the stop on* and the answer
|
||
// is the same for either: he is at the counter and the next thing he does
|
||
// is collect. Splitting [StopState.arrived] out of `active` was about the
|
||
// word the row prints, not about what the sheet offers next.
|
||
StopState.arrived || StopState.active => StopStatus.arrived,
|
||
_ => null,
|
||
};
|
||
if (rung == null) return;
|
||
|
||
// Siblings at the same counter, on the same rung.
|
||
final batch = [
|
||
for (final s in MilkRun.sameSourceAs(stop, _allTripStops))
|
||
if (stopStateOf(
|
||
s,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
) ==
|
||
state)
|
||
s,
|
||
];
|
||
final ids = batch.map(MilkRun.idOf).where((id) => id.isNotEmpty).toList();
|
||
if (ids.isEmpty) return;
|
||
|
||
final place = rung == StopStatus.accepted
|
||
? MilkRun.navigationLabel(stop)
|
||
: MilkRun.sourceNameOf(stop);
|
||
|
||
final done = await StopActionSheet.show(
|
||
context,
|
||
stage: rung,
|
||
placeName: place,
|
||
count: ids.length,
|
||
lines: OrderManifest.forGroup(batch),
|
||
onConfirm: (outcome) async {
|
||
setState(() => _busyStopIds.addAll(ids));
|
||
try {
|
||
final missing = outcome.missingOrderIds.toSet();
|
||
final loaded = [
|
||
for (final s in batch)
|
||
if (!missing.contains(MilkRun.idOf(s))) s,
|
||
];
|
||
// The first refusal is the answer for the whole batch: they are one
|
||
// counter, one slide and one server call each, so a hub that
|
||
// refused the first has refused the event.
|
||
String? refusal;
|
||
for (final s in loaded) {
|
||
refusal ??= await _advanceStop(
|
||
s,
|
||
rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED',
|
||
);
|
||
}
|
||
if (refusal != null) return refusal;
|
||
if (rung == StopStatus.arrived) {
|
||
await saveOrderLabels({
|
||
for (final line in OrderManifest.forGroup(loaded))
|
||
line.orderId: line.label,
|
||
});
|
||
}
|
||
if (missing.isNotEmpty) {
|
||
await addNotLoadedOrderIds(missing.toList());
|
||
if (mounted) {
|
||
setState(() {
|
||
for (final id in missing) {
|
||
_hiddenOrderIds.add(id);
|
||
_ordersMap.remove(id);
|
||
_orderIds.remove(id);
|
||
_selectedOrders.remove(id);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if (outcome.photoPath != null) {
|
||
unawaited(_uploadLoadProof(outcome.photoPath!, place));
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('[RUNG] $ids failed: $e');
|
||
return 'Could not update — check your connection and try again';
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.removeAll(ids));
|
||
}
|
||
},
|
||
);
|
||
|
||
if (!done || !mounted) return;
|
||
AppFeedback.successGlobal(
|
||
rung == StopStatus.accepted
|
||
? 'Arrived · ${ids.length} ready to collect'
|
||
: '${ids.length} picked up — deliver them from '
|
||
'${ServiceProfile.active.workTabLabel}',
|
||
);
|
||
await _fetchQueues();
|
||
}
|
||
|
||
/// Opens turn-by-turn navigation for one stop.
|
||
///
|
||
/// The destination is [MilkRun.navigationTarget]'s, not this screen's, so the
|
||
/// row icon, the detail sheet and the stop map cannot disagree about where a
|
||
/// given order is going. Silent when the stop carries no coordinates for its
|
||
/// current leg — the row hides the control in that case, so reaching here
|
||
/// without one means the data changed under a rebuild.
|
||
Future<void> _navigateToStop(Map<String, dynamic> stop) async {
|
||
final target = MilkRun.navigationTarget(stop, collectedIds: _collectedIds);
|
||
if (target == null) {
|
||
if (mounted) {
|
||
AppFeedback.info(context, 'No location on this stop yet');
|
||
}
|
||
return;
|
||
}
|
||
await _openExternalNavigation(target.lat, target.lng);
|
||
}
|
||
|
||
/// Hands the coordinates to the phone's own maps app.
|
||
///
|
||
/// Native intent first, web as the fallback — the same order the stop map
|
||
/// screen uses, so a rider gets the same app whichever control he presses.
|
||
Future<void> _openExternalNavigation(double lat, double lng) async {
|
||
try {
|
||
final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d');
|
||
if (await launchUrl(native, mode: LaunchMode.externalApplication)) return;
|
||
} catch (_) {}
|
||
try {
|
||
await launchUrl(
|
||
Uri.parse(
|
||
'https://www.google.com/maps/dir/?api=1'
|
||
'&destination=$lat,$lng&travelmode=driving',
|
||
),
|
||
mode: LaunchMode.externalApplication,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[NAV] could not open maps: $e');
|
||
}
|
||
}
|
||
|
||
/// The rung the ticked stops are all on, and therefore what the bar offers.
|
||
///
|
||
/// Mixed selections fall back to accepting: that is the only rung that is
|
||
/// safe to run against a set in different states, because it is a no-op on
|
||
/// anything already taken on.
|
||
SelectionAction _selectionAction() {
|
||
if (!ServiceProfile.active.handsOffAtCollection) {
|
||
return SelectionAction.accept;
|
||
}
|
||
final states = <StopState>{
|
||
for (final id in _selectedStopIds)
|
||
if (_stopById(id) != null)
|
||
stopStateOf(
|
||
_stopById(id)!,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
),
|
||
};
|
||
if (states.length != 1) return SelectionAction.accept;
|
||
return switch (states.first) {
|
||
StopState.accepted => SelectionAction.arrive,
|
||
// He is at the counter — the next thing he does is collect. Offering
|
||
// *Accept* here, which is what the missing arrived case produced, sends
|
||
// him back two rungs on a stop he is standing at.
|
||
StopState.arrived || StopState.active => SelectionAction.pickUp,
|
||
_ => SelectionAction.accept,
|
||
};
|
||
}
|
||
|
||
Map<String, dynamic>? _stopById(String orderId) {
|
||
for (final s in _allTripStops) {
|
||
if ((s['orderid'] ?? '').toString() == orderId) return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// The stop a bulk selection refers to, wherever it currently lives.
|
||
///
|
||
/// ── Why [_ordersMap] on its own could only ever accept ──
|
||
///
|
||
/// `_ordersMap` is built from `validOrders`, which is deliberately the
|
||
/// *undecided* stops only — so a stop leaves it the moment it is accepted.
|
||
/// Every rung past ACCEPT looked its stop up there, got null and `continue`d,
|
||
/// and the label is derived from the same map: a selection of accepted stops
|
||
/// read back as `ACCEPT`, so the button said **Accept Bookings** and posted
|
||
/// another accept. `Move to Arrived` and `Move to Picked` were unreachable
|
||
/// from the selection bar even though [_handleBulkAction] implements both.
|
||
///
|
||
/// [_allTripStops] keeps every stop in every state, which is exactly what the
|
||
/// rungs past acceptance need. On a milk run the order stays on Home to be
|
||
/// walked accepted → arrived → picked — see [MilkRun].
|
||
Map<String, dynamic>? _bulkStopFor(String orderId) =>
|
||
_ordersMap[orderId] ?? _stopById(orderId);
|
||
|
||
/// Walks every ticked stop up one rung, in one gesture.
|
||
///
|
||
/// This is the shape of the job at a kitchen: five parcels come off one
|
||
/// counter in one handover, so the rider ticks the five and confirms once —
|
||
/// not five times, twice. `PICKED` is also the hand-off, so the batch that
|
||
/// finishes it is the batch that lands on the deliveries tab.
|
||
///
|
||
/// ── Two steps, on purpose ──
|
||
///
|
||
/// The bar's button *opens* the confirmation; [StopActionSheet] carries the
|
||
/// slide that commits it. Pressing "Mark as Picked" with a crate in one hand
|
||
/// must not be the thing that writes to the hub — the press is easy to make
|
||
/// by accident, the slide is not, and the sheet is where there is finally
|
||
/// room to say which counter and how many orders he is speaking for.
|
||
Future<void> _advanceSelection() async {
|
||
final action = _selectionAction();
|
||
final ids = _selectedStopIds.toList();
|
||
if (ids.isEmpty || action == SelectionAction.accept) return;
|
||
|
||
final stops = [
|
||
for (final id in ids)
|
||
if (_stopById(id) != null) _stopById(id)!,
|
||
];
|
||
if (stops.isEmpty) return;
|
||
|
||
final arriving = action == SelectionAction.arrive;
|
||
// Where he is. Every stop in a valid selection is on the same rung; when
|
||
// they are also from one counter — the normal case, and the one the rider
|
||
// is standing in — the sheet can name it. A mixed pick says how many
|
||
// places instead of naming the wrong one.
|
||
final places = {
|
||
for (final s in stops)
|
||
arriving ? MilkRun.navigationLabel(s) : MilkRun.sourceNameOf(s),
|
||
}..removeWhere((p) => p.trim().isEmpty);
|
||
final place = places.length == 1 ? places.first : '';
|
||
|
||
final done = await StopActionSheet.show(
|
||
context,
|
||
stage: arriving ? StopStatus.accepted : StopStatus.arrived,
|
||
placeName: place,
|
||
count: ids.length,
|
||
// Named, with the bag each one travels in — this is the list he checks
|
||
// against the shelf in front of him. One order, one bag; see
|
||
// [OrderManifest].
|
||
lines: OrderManifest.forGroup(stops),
|
||
onConfirm: (outcome) async {
|
||
setState(() => _busyStopIds.addAll(ids));
|
||
try {
|
||
// ── A bag the counter could not supply ──
|
||
//
|
||
// It does not move up the ladder: reporting it as picked would put a
|
||
// lunch on the round that is not in the box. It leaves the route with
|
||
// its own record instead — not a skip and not a cancellation, because
|
||
// nobody was visited and the customer did nothing. This is the
|
||
// missing-item path the separate collect sheet used to own; it lives
|
||
// here now because this is the only place a pickup is confirmed.
|
||
final missing = outcome.missingOrderIds.toSet();
|
||
final loaded = [
|
||
for (final stop in stops)
|
||
if (!missing.contains(MilkRun.idOf(stop))) stop,
|
||
];
|
||
|
||
String? refusal;
|
||
for (final stop in loaded) {
|
||
refusal ??= await _advanceStop(stop, action.status);
|
||
}
|
||
if (refusal != null) return refusal;
|
||
// ── The pairing is fixed here, and never recomputed ──
|
||
//
|
||
// Joe is Bag 1 of the five that left this counter. Deriving that
|
||
// later from whatever list is on screen breaks it the moment one is
|
||
// delivered, so it is written down at the one moment it is true and
|
||
// read back unchanged for the rest of the round. See [OrderManifest].
|
||
if (action == SelectionAction.pickUp) {
|
||
await saveOrderLabels({
|
||
for (final line in OrderManifest.forGroup(loaded))
|
||
line.orderId: line.label,
|
||
});
|
||
}
|
||
if (missing.isNotEmpty) {
|
||
await addNotLoadedOrderIds(missing.toList());
|
||
}
|
||
// One shot of the whole load, uploaded against the load rather than
|
||
// any one order. Fire-and-forget: a rider at a hatch must not wait on
|
||
// kitchen wifi, and the pickup is recorded either way.
|
||
if (outcome.photoPath != null) {
|
||
unawaited(_uploadLoadProof(outcome.photoPath!, place));
|
||
}
|
||
// ── Collecting is not setting off ──
|
||
//
|
||
// The release used to ride along with the pickup here, so the moment
|
||
// the rider slid to confirm five bags at a counter the consignments
|
||
// went `Out_for_Delivery` and the hub's board showed five *active*
|
||
// deliveries — for food still on the shelf in front of him. Picked
|
||
// and out-for-delivery are two different claims about where the load
|
||
// is, and only he can make the second one.
|
||
//
|
||
// It moved to where the load is: the START DELIVERY bar at the foot
|
||
// of the deliveries tab. See [MyPickups.startRound].
|
||
if (mounted && missing.isNotEmpty) {
|
||
setState(() {
|
||
for (final id in missing) {
|
||
_hiddenOrderIds.add(id);
|
||
_ordersMap.remove(id);
|
||
_orderIds.remove(id);
|
||
_selectedOrders.remove(id);
|
||
}
|
||
});
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('[RUNG] $ids failed: $e');
|
||
return 'Could not update — check your connection and try again';
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.removeAll(ids));
|
||
}
|
||
},
|
||
);
|
||
|
||
if (!done || !mounted) return;
|
||
// The selection has served its purpose — and on a pick-up it now points at
|
||
// orders that have left this screen for the deliveries tab.
|
||
setState(() => _selectedStopIds = <String>{});
|
||
AppFeedback.successGlobal(
|
||
action == SelectionAction.pickUp
|
||
? '${ids.length} picked up — deliver them from '
|
||
'${ServiceProfile.active.workTabLabel}'
|
||
: 'Arrived · ${ids.length} ready to collect',
|
||
);
|
||
await _fetchQueues();
|
||
}
|
||
|
||
void _toggleStopSelection(String orderId) {
|
||
// Unticking is never blocked — a rider must always be able to back out of
|
||
// a selection he is halfway through.
|
||
if (_selectedStopIds.contains(orderId)) {
|
||
setState(() => _selectedStopIds = {..._selectedStopIds}..remove(orderId));
|
||
return;
|
||
}
|
||
|
||
// ── One kitchen at a time, once he is past accepting ──
|
||
//
|
||
// Acceptance is per order and crosses kitchens freely: he takes on all six
|
||
// of the morning's orders in one press, whichever counter they come from.
|
||
//
|
||
// **Pickup does not.** Arriving and collecting happen at one counter, and a
|
||
// selection spanning two kitchens would post "arrived" for a kitchen the
|
||
// rider is nowhere near and mark bags collected that are still in another
|
||
// shop's warmer. So the moment the ticked set is accepted work, it is bound
|
||
// to a single source.
|
||
//
|
||
// Refused with the reason rather than silently swapping the selection:
|
||
// dropping his existing ticks to honour the newest tap loses work he did
|
||
// deliberately, and he would not see which ones went.
|
||
final incoming = _stopById(orderId);
|
||
if (incoming != null && _isPickupSelection && _selectedStopIds.isNotEmpty) {
|
||
final anchor = _stopById(_selectedStopIds.first);
|
||
if (anchor != null && !MilkRun.sameSource(anchor, incoming)) {
|
||
final anchorName = MilkRun.sourceNameOf(anchor);
|
||
AppFeedback.info(
|
||
context,
|
||
anchorName.isEmpty
|
||
? 'Collect one kitchen at a time — finish this pickup first'
|
||
: 'Finish the $anchorName pickup first, then this one',
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() => _selectedStopIds = {..._selectedStopIds}..add(orderId));
|
||
}
|
||
|
||
/// True when the ticked set is accepted work — i.e. the next slide is a
|
||
/// pickup rung rather than an accept, and the kitchen bound applies.
|
||
bool get _isPickupSelection {
|
||
if (!ServiceProfile.active.handsOffAtCollection) return false;
|
||
for (final id in _selectedStopIds) {
|
||
final stop = _stopById(id);
|
||
if (stop == null) continue;
|
||
final state = stopStateOf(
|
||
stop,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
collectedIds: _collectedIds,
|
||
);
|
||
if (state == StopState.accepted || state.isAtSource) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Declines exactly the stops the rider ticked, after asking why once.
|
||
///
|
||
/// The mirror of [_acceptSelectedStops], and it works the same way: one
|
||
/// deliberate press on a set he assembled himself, rather than a control on
|
||
/// every row. One reason covers the batch — a rider ticking four stops is
|
||
/// declining them for one cause ("all past my area", "no time left"), and
|
||
/// asking four times would make him pick something, anything, four times.
|
||
Future<void> _rejectSelectedStops(List<String> orderIds) async {
|
||
final stops = orderIds
|
||
.map((id) => _ordersMap[id])
|
||
.whereType<Map<String, dynamic>>()
|
||
.toList();
|
||
if (stops.isEmpty || _acceptingTripId != null) return;
|
||
|
||
final reason = await _askRejectReason(stops.length);
|
||
if (reason == null || !mounted) return;
|
||
|
||
setState(() => _acceptingTripId = 'selection');
|
||
try {
|
||
for (final stop in stops) {
|
||
await _commitRejection(
|
||
stop,
|
||
reason: reason,
|
||
message: '',
|
||
notify: false,
|
||
);
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_selectedStopIds = {..._selectedStopIds}..removeAll(orderIds);
|
||
});
|
||
_showStopSnack(
|
||
stops.length == 1
|
||
? 'Stop rejected — your office has been told'
|
||
: '${stops.length} stops rejected — your office has been told',
|
||
ColorConstants.secondaryText,
|
||
);
|
||
await _fetchQueues();
|
||
} finally {
|
||
if (mounted) setState(() => _acceptingTripId = null);
|
||
}
|
||
}
|
||
|
||
/// Reject reasons, phrased as things that actually happen on a route.
|
||
///
|
||
/// A reason is required because the hub needs to know whether to re-route the
|
||
/// stop to another miler or call the customer — "rejected" with no cause is an
|
||
/// operational dead end.
|
||
Future<String?> _askRejectReason(int count) async {
|
||
const reasons = <String>[
|
||
'Too far from my route',
|
||
'Shop / customer closed today',
|
||
'Parcel too large for my box',
|
||
'Not enough time left in my shift',
|
||
'Address looks wrong',
|
||
];
|
||
|
||
// Through the kit; content unchanged. (This one also applied a bare
|
||
// `SafeArea` INSIDE an opaque sheet — the scaffold's single inset rule
|
||
// replaces it.)
|
||
return showMilerSheet<String>(
|
||
context,
|
||
builder: (ctx) => MilerSheetScaffold(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
MilerSheetHeader(
|
||
title: count == 1
|
||
? 'Why are you rejecting this stop?'
|
||
: 'Why are you rejecting these $count stops?',
|
||
subtitle: count == 1
|
||
? 'Your office needs a reason to re-route it to someone else.'
|
||
: 'Your office needs a reason to re-route them to someone else.',
|
||
),
|
||
SizedBox(height: 14.h),
|
||
for (final r in reasons) ...[
|
||
MilerButton(
|
||
label: r,
|
||
variant: MilerButtonVariant.outlined,
|
||
color: ColorConstants.slateText,
|
||
height: ButtonSizes.secondary,
|
||
onPressed: () => Navigator.pop(ctx, r),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
],
|
||
SizedBox(height: 4.h),
|
||
MilerButton(
|
||
label: count == 1 ? 'Keep this stop' : 'Keep them',
|
||
variant: MilerButtonVariant.tonal,
|
||
color: ColorConstants.acceptGreen,
|
||
onPressed: () => Navigator.pop(ctx, null),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Returns a rejected stop to undecided.
|
||
///
|
||
/// Not an accept: it goes back to the top of the screen as a tickable stop,
|
||
/// and it is taken the same way as everything else — ticked, then accepted
|
||
/// from the bar. A row control that quietly committed work would be the
|
||
/// per-stop Accept button coming back through the side door.
|
||
Future<void> _unrejectStop(Map<String, dynamic> stop) async {
|
||
final orderId = (stop['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty || _busyStopIds.contains(orderId)) return;
|
||
|
||
setState(() => _busyStopIds.add(orderId));
|
||
try {
|
||
await removeRejectedOrderIds([orderId]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_rejectedIds = {..._rejectedIds}..remove(orderId);
|
||
_hiddenOrderIds.remove(orderId);
|
||
stop['orderstatus'] = '';
|
||
});
|
||
_showStopSnack(
|
||
'Back in the list — tick it to take it',
|
||
ColorConstants.secondaryText,
|
||
);
|
||
await _fetchQueues();
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.remove(orderId));
|
||
}
|
||
}
|
||
|
||
/// Takes back the acceptance the rider just made, from the UNDO on its
|
||
/// snackbar.
|
||
///
|
||
/// A rejection on the wire, not a silent local rollback: the accept already
|
||
/// went to the hub, so those stops are assigned to this miler until something
|
||
/// says otherwise. No reason sheet — he is correcting a tap he made seconds
|
||
/// ago, and interrupting an undo with a questionnaire is how you make people
|
||
/// stop using undo. The reason is stated for him so the hub can still tell
|
||
/// this apart from a considered refusal.
|
||
///
|
||
/// One snackbar and one refetch for the whole batch, which is why
|
||
/// [_commitRejection] takes [notify].
|
||
Future<void> _undoAcceptedBatch(List<Map<String, dynamic>> stops) async {
|
||
final taken = stops
|
||
.where((s) => _acceptedIds.contains((s['orderid'] ?? '').toString()))
|
||
.toList();
|
||
if (taken.isEmpty) return;
|
||
|
||
for (final stop in taken) {
|
||
await _commitRejection(
|
||
stop,
|
||
reason: 'Accepted by mistake — undone by the miler',
|
||
message: '',
|
||
notify: false,
|
||
);
|
||
}
|
||
|
||
if (!mounted) return;
|
||
// Undo is pressed from the Bookings tab, because that is where the accept
|
||
// just sent him. Put him back where the stop went, or he is left looking at
|
||
// a list that silently lost a row.
|
||
BottomPage.goToTab(0);
|
||
_showStopSnack(
|
||
taken.length == 1
|
||
? 'Acceptance undone — the stop is back with your office'
|
||
: '${taken.length} acceptances undone — back with your office',
|
||
ColorConstants.secondaryText,
|
||
);
|
||
await _fetchQueues();
|
||
}
|
||
|
||
/// Tell the hub, drop the local acceptance, record the rejection, say so.
|
||
///
|
||
/// [notify] off suppresses the per-stop snackbar and refetch so a batch can
|
||
/// do both once at the end.
|
||
Future<void> _commitRejection(
|
||
Map<String, dynamic> stop, {
|
||
required String reason,
|
||
required String message,
|
||
bool notify = true,
|
||
}) async {
|
||
final orderId = (stop['orderid'] ?? '').toString();
|
||
if (orderId.isEmpty || _busyStopIds.contains(orderId)) return;
|
||
|
||
setState(() => _busyStopIds.add(orderId));
|
||
try {
|
||
final dc = Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>()
|
||
: Get.put(PickupsController(), permanent: true);
|
||
|
||
bool success = false;
|
||
try {
|
||
success = await dc.updateRejectedStatus(
|
||
pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0,
|
||
orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0,
|
||
notes: reason,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[STOP_REJECT] $orderId failed: $e');
|
||
}
|
||
|
||
await addRejectedOrderIds([orderId]);
|
||
if (success) dc.triggerRefresh();
|
||
|
||
// ── A declined stop is filed, not deleted ──
|
||
//
|
||
// It used to stay on Home, struck through, with an UNDO beside it, and
|
||
// it was recorded nowhere else — so the only record of a decision the
|
||
// hub will ask about lived on the one screen the rider clears as he
|
||
// works. Now it leaves Home immediately (see `TripCard._showsOnHome`)
|
||
// and lands on Activity with the rest of the day, stamped `rejected` so
|
||
// the row can say **Declined** rather than pretending it was completed.
|
||
await addCompletedBookings([stop], terminalStatus: 'rejected');
|
||
|
||
if (!mounted) return;
|
||
await removeAcceptedBookings([orderId]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_rejectedIds = {..._rejectedIds, orderId};
|
||
_acceptedIds = {..._acceptedIds}..remove(orderId);
|
||
_selectedStopIds = {..._selectedStopIds}..remove(orderId);
|
||
_hiddenOrderIds.add(orderId);
|
||
_ordersMap.remove(orderId);
|
||
_orderIds.remove(orderId);
|
||
_selectedOrders.remove(orderId);
|
||
});
|
||
if (notify) {
|
||
_showStopSnack(message, ColorConstants.secondaryText);
|
||
await _fetchQueues();
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _busyStopIds.remove(orderId));
|
||
}
|
||
}
|
||
|
||
/// One-line confirmation, optionally carrying the action that reverses what
|
||
/// it is confirming.
|
||
///
|
||
/// The action is a plain text label rather than anything filled: Material is
|
||
/// explicit that a snackbar action must not out-shout the message it sits
|
||
/// beside, and this one is a safety net, not the main path.
|
||
/// ── A confirmation, not a thing to dismiss ──
|
||
///
|
||
/// This was a Material `SnackBar` at 4 seconds — 8 on an accept, to leave
|
||
/// room for an UNDO. On the rider's phone that reads as a panel that has
|
||
/// *arrived and stayed*: it covers the foot of the route, it sits over the
|
||
/// floating nav bar's neighbourhood, and the only obvious way to be rid of it
|
||
/// is to swipe it down. Confirming six accepted stops is not worth eight
|
||
/// seconds of the screen the rider is trying to keep working on.
|
||
///
|
||
/// `Flushbar` (another_flushbar, pub.dev) at **2 seconds**: it announces, it
|
||
/// leaves, and nothing has to be dismissed. It also animates in and out on
|
||
/// its own clock rather than the Scaffold's, so it is unaffected by the tab
|
||
/// switch an accept triggers.
|
||
///
|
||
/// [duration] is kept as a parameter but defaults to the 2s the rider asked
|
||
/// for; anything that genuinely needs longer has to say so at the call site.
|
||
void _showStopSnack(
|
||
String message,
|
||
Color color, {
|
||
String? actionLabel,
|
||
VoidCallback? onAction,
|
||
Duration duration = const Duration(seconds: 2),
|
||
}) {
|
||
if (!mounted) return;
|
||
|
||
// One at a time. Two accepts in quick succession would otherwise stack.
|
||
_flushbar?.dismiss();
|
||
|
||
final bar = Flushbar<void>(
|
||
messageText: Text(
|
||
message,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
icon: Icon(
|
||
LucideIcons.circleCheck,
|
||
size: 20.sp,
|
||
color: Colors.white.withValues(alpha: 0.95),
|
||
),
|
||
backgroundColor: color,
|
||
duration: duration,
|
||
flushbarPosition: FlushbarPosition.BOTTOM,
|
||
flushbarStyle: FlushbarStyle.FLOATING,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
// Clear of the floating nav bar — the bar is a sibling layer painted over
|
||
// this Scaffold, so a toast measured from the Scaffold's own bottom lands
|
||
// underneath it.
|
||
margin: EdgeInsets.fromLTRB(
|
||
16.w,
|
||
0,
|
||
16.w,
|
||
BottomPage.bottomInset(context) + 8.h,
|
||
),
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
|
||
animationDuration: const Duration(milliseconds: 260),
|
||
forwardAnimationCurve: Curves.easeOutCubic,
|
||
reverseAnimationCurve: Curves.easeInCubic,
|
||
// Two seconds is not long enough to reach for an action, so a toast that
|
||
// carries one is a toast making a promise it cannot keep. Anything
|
||
// reversible is reversed from the tab the work landed on.
|
||
mainButton: (actionLabel == null || onAction == null)
|
||
? null
|
||
: TextButton(
|
||
onPressed: () {
|
||
_flushbar?.dismiss();
|
||
onAction();
|
||
},
|
||
child: Text(
|
||
actionLabel,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontWeight: FontWeight.w800,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
_flushbar = bar;
|
||
bar.show(context);
|
||
}
|
||
|
||
/// The toast currently on screen, so a second one replaces it rather than
|
||
/// stacking behind it.
|
||
Flushbar<void>? _flushbar;
|
||
|
||
/// The stops the last accept actually took, held for the UNDO on its
|
||
/// snackbar. Cleared implicitly — the next accept overwrites it, and undoing
|
||
/// checks each stop is still accepted before touching it.
|
||
List<Map<String, dynamic>> _lastAcceptedBatch = const [];
|
||
|
||
/// Walks the ticked stops up one rung: ACCEPT → ACCEPTED → ARRIVED → PICKED.
|
||
///
|
||
/// ── Where the rider ends up is the line's decision, not the caller's ──
|
||
///
|
||
/// This took a `navigateToBookings` flag, defaulted to `true`, and the two
|
||
/// branches it selected disagreed about the thing [ServiceProfile] already
|
||
/// answers. The tick-based path passed `false` and got the profile-aware
|
||
/// branch; the bulk action button passed nothing, got the default, and hit an
|
||
/// unconditional `Get.offAll(BottomPage(initialIndex: 1))`.
|
||
///
|
||
/// On a **milk run** that is the wrong move and it broke the whole ladder.
|
||
/// The rider accepts at a kitchen, and the order has to stay on Home to be
|
||
/// walked to arrived and then picked — see [MilkRun]. Throwing him at the
|
||
/// work tab on accept sent him to a tab that was correctly empty, made the
|
||
/// accept look like it had filed his order somewhere else, and left the two
|
||
/// rungs that follow it unreachable from where he was standing.
|
||
///
|
||
/// So the flag is gone. `handsOffAtCollection` is the one rule and it is read
|
||
/// in one place: the line that hands off at *acceptance* (logistics) moves
|
||
/// him, the line that hands off at *collection* (milk man) keeps him on Home.
|
||
Future<void> _handleBulkAction() async {
|
||
final selectedOrderIds = _selectedOrders.entries
|
||
.where((e) => e.value)
|
||
.map((e) => e.key)
|
||
.toList();
|
||
|
||
if (selectedOrderIds.isEmpty) return;
|
||
|
||
final statuses = selectedOrderIds
|
||
.map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {}))
|
||
.toSet();
|
||
|
||
String targetStatus;
|
||
if (statuses.length == 1 && statuses.first == 'ACCEPT') {
|
||
targetStatus = 'ACCEPTED';
|
||
} else if (statuses.length == 1 && statuses.first == 'ACCEPTED') {
|
||
targetStatus = 'ARRIVED';
|
||
} else if (statuses.length == 1 && statuses.first == 'ARRIVED') {
|
||
targetStatus = 'PICKED';
|
||
} else {
|
||
targetStatus = 'ACCEPTED';
|
||
}
|
||
|
||
if (targetStatus == 'ARRIVED') {
|
||
// ── The 3-second fail-OPEN is gone ──
|
||
//
|
||
// This was wrapped in `.timeout(3s, onTimeout: () => everything inside
|
||
// the fence)`. Each check took its own 8-second GPS fix, so twenty of
|
||
// them could never finish in three seconds — the timeout fired on
|
||
// essentially every bulk arrival and waved every order through whatever
|
||
// the distance was. A hole straight through the 100 m rule, and one that
|
||
// looked like a safety net.
|
||
//
|
||
// It is not needed now: [Geofence] reuses one live fix for
|
||
// [kGeofenceFixReuse], so twenty checks cost one GPS settle and complete
|
||
// in milliseconds. If the fix itself cannot be had, each check refuses on
|
||
// its own terms and says which of location-off / permission / no-signal
|
||
// it was — which is the answer the rider can act on, and the one a
|
||
// blanket "allow everything" was hiding.
|
||
final proximityResults = await _checkProximityForOrders(selectedOrderIds);
|
||
|
||
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 ${kGeofenceRadiusMetres.round()} m of the pickup '
|
||
'location to mark it arrived.'
|
||
: 'Some selected bookings are too far from their pickup locations.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
XFile? bulkPhoto;
|
||
if (targetStatus == 'PICKED') {
|
||
try {
|
||
final ImagePicker picker = ImagePicker();
|
||
bulkPhoto = await picker.pickImage(
|
||
source: ImageSource.camera,
|
||
imageQuality: 50,
|
||
);
|
||
if (bulkPhoto == null) return;
|
||
} catch (e) {
|
||
debugPrint('[BULK] Camera error: $e');
|
||
return;
|
||
}
|
||
}
|
||
|
||
showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
barrierColor: Colors.black.withValues(alpha: 0.3),
|
||
builder: (ctx) => Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.all(24),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator(),
|
||
SizedBox(height: 14.h),
|
||
Text(
|
||
'Updating ${selectedOrderIds.length} bookings...',
|
||
style: TextStyle(
|
||
color: _textSecondary,
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
bool bulkSucceeded = false;
|
||
int successCount = 0;
|
||
try {
|
||
final dc = Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>()
|
||
: Get.put(PickupsController(), permanent: true);
|
||
|
||
// The rider is physically at one place for the whole batch, so resolve
|
||
// the current location once and reuse it for every booking's update.
|
||
final bulkRiderLoc = await _getValidCoordinates();
|
||
final bulkRidersLat = bulkRiderLoc?.$1 ?? '0';
|
||
final bulkRidersLng = bulkRiderLoc?.$2 ?? '0';
|
||
|
||
// A bulk pick captures a single proof photo; upload it once and attach the
|
||
// resulting URL to each picked booking below.
|
||
String? bulkProofImageUrl;
|
||
if (bulkPhoto != null) {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userId = int.tryParse(prefs.getString('userid') ?? '0') ?? 0;
|
||
if (userId > 0) {
|
||
bulkProofImageUrl = await dc.uploadProofImage(
|
||
File(bulkPhoto.path),
|
||
'picked',
|
||
userId,
|
||
0,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[BULK] Upload error: $e');
|
||
}
|
||
}
|
||
|
||
final List<Map<String, dynamic>> acceptedBookings = [];
|
||
|
||
/// Orders this batch confirmed as collected, mirrored into [_collectedIds]
|
||
/// once the loop is done so the cards re-read their leg immediately rather
|
||
/// than at the next poll.
|
||
final Set<String> bulkCollected = <String>{};
|
||
|
||
// ── Phase 1: the network, overlapped ──
|
||
//
|
||
// This was a plain sequential `for`, so twenty orders meant twenty full
|
||
// round trips end to end — status call, GPS fix, routing call and four
|
||
// preferences writes each, nothing overlapping anything. Measured at
|
||
// 3–5 s per order, which is the 1–2 minutes riders were reporting for a
|
||
// twenty-bag counter pick.
|
||
//
|
||
// The writes are independent — different bookings, different
|
||
// [MutationGuard] keys (`picked:1042`), and the backend carries an
|
||
// `Idempotency-Key` per action — so they can be in flight together. The
|
||
// *local* bookkeeping is NOT done here: it is collected and applied once,
|
||
// below, so the order of local state changes stays deterministic and a
|
||
// growing JSON blob is not rewritten twenty times.
|
||
//
|
||
// Bounded to [_bulkLanes] rather than `Future.wait` over everything:
|
||
// twenty simultaneous requests on a rider's 3G connection queue at the
|
||
// radio and finish slower than six, and it is a kinder shape for the
|
||
// backend than a thundering herd from one handset.
|
||
final List<String> bulkIds = [];
|
||
final Map<String, Map<String, dynamic>> bulkStops = {};
|
||
for (final id in selectedOrderIds) {
|
||
// Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every
|
||
// rung after the first was silently skipped here. See [_bulkStopFor].
|
||
final stop = _bulkStopFor(id);
|
||
if (stop == null) continue;
|
||
bulkIds.add(id);
|
||
bulkStops[id] = stop;
|
||
}
|
||
|
||
/// Which orders the backend confirmed.
|
||
final Set<String> bulkOk = <String>{};
|
||
|
||
Future<void> runOne(String orderId) async {
|
||
final Booking = bulkStops[orderId]!;
|
||
|
||
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) {
|
||
debugPrint(
|
||
'[BULK] accept REFUSED for $orderId — leaving it on Home',
|
||
);
|
||
}
|
||
} else if (targetStatus == 'ARRIVED') {
|
||
debugPrint('[BULK] Arriving Booking $orderId');
|
||
success = await dc.updateArrivedStatus(
|
||
pickupId: pickupId,
|
||
orderHeaderId: orderHeaderId,
|
||
ridersLat: bulkRidersLat,
|
||
ridersLng: bulkRidersLng,
|
||
pickupLat: pickupLat,
|
||
pickupLng: pickupLng,
|
||
);
|
||
// ── The bulk path recorded nothing at all ──
|
||
//
|
||
// Accept hides the row, PICKED writes two stores; ARRIVED wrote
|
||
// neither, and this method finishes with `_fetchQueues()`. So a
|
||
// successful bulk arrival was a network call, a success count and
|
||
// a row that came straight back on ACCEPTED — which is what "mark
|
||
// as arrived does nothing" looked like from the outside.
|
||
if (success) Booking['orderstatus'] = 'arrived';
|
||
} 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,
|
||
// ── Pre-computed, so the routing call never fires ──
|
||
//
|
||
// Left at 0 this falls through to priority 3, which is an OSRM
|
||
// road-distance request per order with a 6-second timeout. On a
|
||
// bulk pick the rider never "started" each stop individually, so
|
||
// the cumulative-tracking key is absent for most of them and that
|
||
// request ran for nearly all twenty — to compute a number that
|
||
// `UpdatePickupProvider` never reads and `pickup-complete` never
|
||
// sends. The backend derives earnings from the coordinates by its
|
||
// own haversine; this figure only feeds Activity's local
|
||
// "km ridden".
|
||
//
|
||
// So it is measured here, on the device, for free — the same
|
||
// straight line the priority-3 branch already falls back to when
|
||
// OSRM fails.
|
||
actualKms: _bulkLegKm(
|
||
bulkRidersLat,
|
||
bulkRidersLng,
|
||
pickupLat,
|
||
pickupLng,
|
||
),
|
||
proofImage: bulkProofImageUrl,
|
||
);
|
||
if (success) {
|
||
_startPickupPosting(Booking);
|
||
|
||
// ── The write that says the load is in his hands ──
|
||
//
|
||
// `_advanceStop` records this rung in the collected store and
|
||
// this branch did not, so a bag picked from the **selection bar**
|
||
// was never marked as carried. That one omission is what put
|
||
// **Start Pickup** on a delivery: [MilkRun.workingKind] reads
|
||
// exactly this set to decide which leg a stop is on, and the
|
||
// booking adapter stamps `type: pickup` on every row, so with an
|
||
// empty set a bag already in the box still described itself as a
|
||
// collection. The same answer feeds `toDrop` on the queue's
|
||
// distance, so the card also measured to the kitchen he had just
|
||
// left instead of to the customer's door — which is the "km and
|
||
// time are wrong" half of it.
|
||
//
|
||
// Recorded only after the backend confirmed, same rule as accept.
|
||
// Applied in phase 2 — see [bulkCollected].
|
||
bulkCollected.add(orderId);
|
||
|
||
// ── Picked is a hand-over on one line and a finish on the other ──
|
||
//
|
||
// This branch closed the booking outright. That is right for
|
||
// **logistics**, where what he collects goes to the hub and the
|
||
// booking's own story ends at the counter.
|
||
//
|
||
// On a **milk run** it is wrong and it loses the order: collection
|
||
// is the *middle* of the day — every bag is followed by a round of
|
||
// drops he makes himself — so filing it as completed sent it to
|
||
// Activity instead of to Deliveries, and the delivery leg had
|
||
// nothing to work. See [ServiceProfile.handsOffAtCollection].
|
||
// The handsOffAtCollection split is applied in phase 2, once,
|
||
// against the whole batch.
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[BULK] Error updating Booking $orderId: $e');
|
||
success = false;
|
||
}
|
||
|
||
if (success) {
|
||
bulkOk.add(orderId);
|
||
} else {
|
||
debugPrint('[BULK] Failed to update Booking $orderId');
|
||
}
|
||
}
|
||
|
||
// Bounded-concurrency drain. `next++` is safe: there is no `await`
|
||
// between reading and incrementing it, and Dart does not preempt inside a
|
||
// synchronous run of statements.
|
||
var next = 0;
|
||
final int lanes = _bulkLanes < bulkIds.length
|
||
? _bulkLanes
|
||
: (bulkIds.isEmpty ? 1 : bulkIds.length);
|
||
await Future.wait(
|
||
List.generate(lanes, (_) async {
|
||
while (true) {
|
||
final i = next++;
|
||
if (i >= bulkIds.length) break;
|
||
await runOne(bulkIds[i]);
|
||
}
|
||
}),
|
||
);
|
||
successCount += bulkOk.length;
|
||
|
||
// ── Phase 2: every local store written once ──
|
||
//
|
||
// Each of these helpers decodes a JSON blob, mutates it and writes it
|
||
// back. Called per order inside the loop that was O(n²) writes against a
|
||
// growing list, and it left a window where a rider backgrounding the app
|
||
// mid-batch had half a batch filed.
|
||
final List<String> okIds = bulkIds.where(bulkOk.contains).toList();
|
||
final List<Map<String, dynamic>> okStops = [
|
||
for (final id in okIds) bulkStops[id]!,
|
||
];
|
||
|
||
if (okIds.isNotEmpty) {
|
||
// ── ARRIVED is deliberately NOT hidden ──
|
||
//
|
||
// Accept and Picked take the row off Home; arriving does not. An
|
||
// arrived stop is still work in front of the rider and stays on the
|
||
// list — see `arrived_stays_on_home_test.dart`. Hiding all three here
|
||
// would have made a bulk arrival look like the stops had vanished.
|
||
if (targetStatus == 'ACCEPTED') {
|
||
_hiddenOrderIds.addAll(okIds);
|
||
acceptedBookings.addAll(okStops);
|
||
} else if (targetStatus == 'ARRIVED') {
|
||
await addArrivedOrderIds(okIds);
|
||
} else if (targetStatus == 'PICKED') {
|
||
_hiddenOrderIds.addAll(okIds);
|
||
await addCollectedOrderIds(okIds);
|
||
// ── Picked is a hand-over on one line and a finish on the other ──
|
||
//
|
||
// Right for **logistics**, where what he collects goes to the base
|
||
// and the booking's own story ends at the counter. On a **milk run**
|
||
// collection is the *middle* of the day — every bag is followed by a
|
||
// round of drops he makes himself — so filing it as completed would
|
||
// send it to Activity instead of Deliveries and the delivery leg
|
||
// would have nothing to work. See [ServiceProfile.handsOffAtCollection].
|
||
if (ServiceProfile.active.handsOffAtCollection) {
|
||
await addAcceptedBookings(okStops);
|
||
} else {
|
||
await removeAcceptedBookings(okIds);
|
||
await addCompletedBookings(okStops);
|
||
}
|
||
// Off the arrived rung — it has been collected.
|
||
await removeArrivedOrderIds(okIds);
|
||
// One copy of the day, and it is now stale — Deliveries must not
|
||
// render these orders from a response that predates the hand-over.
|
||
unawaited(WorkRepository.instance.invalidate());
|
||
}
|
||
}
|
||
|
||
// The leg every card reads is derived from this set, so it has to land
|
||
// before the next build — not at the next poll three seconds later.
|
||
if (bulkCollected.isNotEmpty && mounted) {
|
||
setState(() => _collectedIds = {..._collectedIds, ...bulkCollected});
|
||
}
|
||
|
||
// Persist accepted bookings so the Bookings tab can show them and Home
|
||
// keeps them filtered out.
|
||
if (acceptedBookings.isNotEmpty) {
|
||
await addAcceptedBookings(acceptedBookings);
|
||
await stampOrderEvents(
|
||
acceptedBookings.map((b) => (b['orderid'] ?? '').toString()),
|
||
OrderEvent.accepted,
|
||
);
|
||
}
|
||
|
||
_lastAcceptedBatch = List<Map<String, dynamic>>.from(acceptedBookings);
|
||
|
||
setState(() {
|
||
for (final b in acceptedBookings) {
|
||
final id = (b['orderid'] ?? '').toString();
|
||
_ordersMap.remove(id);
|
||
_orderIds.remove(id);
|
||
}
|
||
_selectedOrders.clear();
|
||
isAllSelected = false;
|
||
});
|
||
bulkSucceeded = acceptedBookings.isNotEmpty || successCount > 0;
|
||
await _fetchQueues();
|
||
|
||
// ── A partial accept has to say so ──
|
||
//
|
||
// The accept path used to skip this message entirely, on the grounds that
|
||
// it navigates away. It now records only what the backend confirmed, so
|
||
// silence would leave the rider looking at stops that did not move with
|
||
// nothing to explain why.
|
||
if (mounted &&
|
||
targetStatus == 'ACCEPTED' &&
|
||
acceptedBookings.length != selectedOrderIds.length) {
|
||
final missed = selectedOrderIds.length - acceptedBookings.length;
|
||
AppFeedback.error(
|
||
context,
|
||
acceptedBookings.isEmpty
|
||
? 'Could not accept — your office did not confirm it. Try again.'
|
||
: '$missed of ${selectedOrderIds.length} could not be accepted — '
|
||
'they are still on Home.',
|
||
);
|
||
}
|
||
|
||
// For accepts we navigate to the Bookings tab below, so skip the inline
|
||
// snackbar in that case (it would be tied to this soon-to-be-replaced page).
|
||
if (mounted && targetStatus != 'ACCEPTED') {
|
||
final total = selectedOrderIds.length;
|
||
final allOk = successCount == total;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
allOk
|
||
? 'Updated $successCount ${total == 1 ? 'booking' : 'bookings'} successfully'
|
||
: 'Updated $successCount of $total bookings — the rest could not be updated',
|
||
style: const TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
backgroundColor: allOk ? _primaryRed : ColorConstants.warning,
|
||
behavior: SnackBarBehavior.floating,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[BULK] Error: $e');
|
||
} finally {
|
||
if (mounted && Navigator.of(context).canPop()) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
|
||
// ── Accepting takes the rider to the work ──
|
||
//
|
||
// This used to stop at a snackbar reading "work it from the Bookings tab",
|
||
// leaving him on Home watching the stop he had just taken disappear off the
|
||
// screen. From where he was standing the accept had deleted his booking:
|
||
// the row was gone from Home, nothing else on screen had changed, and the
|
||
// tab that did have it was one he had to know to go and open — where, for
|
||
// up to three seconds, the poll had not landed yet either, so it *was*
|
||
// genuinely absent when he arrived.
|
||
//
|
||
// So the accept now does what it says: it moves him, and it takes the data
|
||
// with him. [PickupsController.triggerRefresh] makes Bookings re-fetch on
|
||
// the spot rather than at the next tick of its poller, and the switch is an
|
||
// IndexedStack index change — the same ScaffoldMessenger, so the UNDO
|
||
// snackbar below travels with him and is still there to be pressed.
|
||
if (bulkSucceeded && targetStatus == 'ACCEPTED') {
|
||
final taken = _lastAcceptedBatch;
|
||
final n = taken.length;
|
||
|
||
try {
|
||
(Get.isRegistered<PickupsController>()
|
||
? Get.find<PickupsController>()
|
||
: Get.put(PickupsController(), permanent: true))
|
||
.triggerRefresh();
|
||
} catch (e) {
|
||
debugPrint('[BULK] Could not poke Bookings to refresh: $e');
|
||
}
|
||
|
||
// ── Only the line that hands off at accept gets moved ──
|
||
//
|
||
// On **logistics** accepting IS the hand-off: the stop leaves Home for
|
||
// the work tab, so following it there is the whole point — see the note
|
||
// above.
|
||
//
|
||
// On a **milk run** it is not. The order stays on Home to be walked up
|
||
// its ladder — accepted, arrived, picked — and only reaches the
|
||
// deliveries tab once it is in the rider's hands. Jumping him there on
|
||
// accept sent him to a tab that was correctly empty and made the accept
|
||
// look like it had filed his order somewhere else. He then had to find
|
||
// his way back to Home to do the actual collecting.
|
||
//
|
||
// So he stays where the work is. The snackbar below still confirms what
|
||
// he took, and the accepted rows are right there under it.
|
||
if (!ServiceProfile.active.handsOffAtCollection) {
|
||
BottomPage.goToTab(1);
|
||
}
|
||
|
||
// ── The undo window ──
|
||
//
|
||
// Accepting is now one deliberate press on a set the rider assembled by
|
||
// ticking, so a mis-tap is rarer than it was with a button on every row —
|
||
// but it is also *bigger*, because one press can take six stops. Eight
|
||
// seconds to take the whole batch back, the same trade Gmail's delete
|
||
// and Material's snackbar guidance make.
|
||
// Two seconds, and no UNDO on it. An eight-second panel held the foot of
|
||
// the screen open purely so an action nobody could reach in time had
|
||
// somewhere to live; the acceptance itself is reversible from the tab the
|
||
// work lands on, which is where the rider is by the time he changes his
|
||
// mind.
|
||
_showStopSnack(
|
||
n == 1 ? 'Stop accepted' : '$n stops accepted',
|
||
ColorConstants.acceptGreen,
|
||
);
|
||
return;
|
||
}
|
||
|
||
}
|
||
|
||
/// The whole route in one object: load, distance, time windows, shift.
|
||
///
|
||
/// Recomputed each build \u2014 it's a cheap pass over the pending stops, and the
|
||
/// shift countdown has to stay honest as the day burns down.
|
||
RouteBrief get _routeBrief => RouteBrief.from(
|
||
orderIds: _orderIds,
|
||
ordersMap: _ordersMap,
|
||
shiftStart: _shiftStart,
|
||
shiftEnd: _shiftEnd,
|
||
extraStops: _acceptedStops,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// A trip with nothing in it, for a slot the hub has not filled.
|
||
///
|
||
/// Every figure the brief draws is derived from the stops, so an empty list
|
||
/// gives `0 stops left`, a progress track at zero and an em dash under
|
||
/// duration, distance and payment on its own — there is no "empty" variant of
|
||
/// the brief to keep in step with the real one, because this *is* the real
|
||
/// one with nothing in it.
|
||
Trip _unassignedTrip() => Trip.fromStops(
|
||
id: 'unassigned-$_selectedTripIndex',
|
||
stops: const [],
|
||
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
hubLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
);
|
||
|
||
/// One configured [TripCard] for the selected trip.
|
||
///
|
||
/// Home does not render the card as a single widget any more: its summary, its
|
||
/// stop list and its action bar go into the sliver list separately so the
|
||
/// Trip 1 / 2 / 3 bar can be *pinned* between the summary and the stops. This
|
||
/// holds the configuration \u2014 every callback and every id set \u2014 in one place, so
|
||
/// the three sections cannot drift apart. See the note above the section
|
||
/// getters in `trip_card.dart`.
|
||
TripCard _tripCardFor(Trip trip) => TripCard(
|
||
key: ValueKey('trip_${trip.id}'),
|
||
trip: trip,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
// Shift window, pace and cash — the summary strip shows the shift and
|
||
// the trip's own slot as two labelled rows, which is the whole reason
|
||
// the page has to hand this down.
|
||
brief: _routeBrief,
|
||
busyStopIds: _busyStopIds,
|
||
collectedIds: _collectedIds,
|
||
// How far the next pickup is. The same fix the detail sheet and the map
|
||
// already use — a live geocoded position, falling back to the last one
|
||
// stored — so the distance on the route header and the distance inside the
|
||
// stop cannot disagree.
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
// ── The card opens a preview, and the preview hands off ──
|
||
//
|
||
// `Navigate to pickup` used to go straight out to Google Maps. That is one
|
||
// tap too few: handing off means the rider *leaves Miler*, and he was
|
||
// making that commitment on the strength of a kitchen's name and a
|
||
// straight-line distance. The preview draws the leg from where he is
|
||
// standing to the counter and lists what he will collect there; its own
|
||
// primary button is the real hand-off. Built here rather than inside
|
||
// [TripCard] because that class's three build methods are deliberately
|
||
// context-free — Home calls them directly as slivers.
|
||
onPreviewGroup: (g) => PickupPreviewSheet.show(
|
||
context,
|
||
placeName: g.name,
|
||
lines: g.manifest,
|
||
meters: g.meters,
|
||
ride: g.rideTime,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
onNavigate:
|
||
g.stops.isEmpty || MilkRun.navigationTarget(g.stops.first) == null
|
||
? null
|
||
: () {
|
||
Navigator.of(context).pop();
|
||
_navigateToStop(g.stops.first);
|
||
},
|
||
onCall: g.stops.isEmpty || stopPhoneOf(g.stops.first).isEmpty
|
||
? null
|
||
: () {
|
||
Navigator.of(context).pop();
|
||
_callStop(g.stops.first);
|
||
},
|
||
),
|
||
// Only a tenant that collects from a source gets the control; on a parcel
|
||
// route there is nothing to collect from and the header never renders.
|
||
selectedIds: _selectedStopIds,
|
||
onToggleSelect: _toggleStopSelection,
|
||
onSelectAll: (ids) => setState(() => _selectedStopIds = ids.toSet()),
|
||
onAcceptSelected: _acceptSelectedStops,
|
||
// The way back for a stop he declined. It does not accept anything by
|
||
// itself — see the note at the row. (Resuming a *skipped* stop is
|
||
// Activity's control now: a skip is a record of what happened at a
|
||
// door, not an offer waiting on a decision.)
|
||
onUnrejectStop: _unrejectStop,
|
||
// No per-stop or whole-trip accept any more: ticking chooses, the
|
||
// floating bar commits. See the decision-row note in `trip_card.dart`.
|
||
// Map + full address + everything the card truncates.
|
||
onStopTap: (stop) => StopDetailSheet.show(
|
||
context,
|
||
stop: stop,
|
||
stopNumber: trip.stops.indexOf(stop) + 1,
|
||
// Home is the one caller that holds the whole route, so it is the one
|
||
// that can say `Stop 3 of 8`.
|
||
stopCount: trip.stops.length,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
),
|
||
// ── An order tapped inside a kitchen is about its own door ──
|
||
//
|
||
// Same sheet, different subject. The counter is shared by every order in
|
||
// the group and already has a surface of its own — the preview behind the
|
||
// card's third utility, which draws the whole leg to it with the manifest
|
||
// waiting there. So the order's sheet points at the customer instead of
|
||
// repeating the kitchen five times. See [StopDetailSheet.ofOrder].
|
||
onOrderTap: (stop) => StopDetailSheet.show(
|
||
context,
|
||
stop: stop,
|
||
stopNumber: trip.stops.indexOf(stop) + 1,
|
||
stopCount: trip.stops.length,
|
||
riderLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||
ofOrder: true,
|
||
),
|
||
onCallStop: (stop) => _callStop(stop),
|
||
// Setting off, straight from the row. Where it points follows the stop's
|
||
// stage: the kitchen while it is still to be collected, the customer once
|
||
// it is in his hands.
|
||
onNavigateStop: _navigateToStop,
|
||
// The single-stop ladder: a slide sheet per card, alongside the floating
|
||
// bar's batch version.
|
||
onAdvanceStop: _advanceStopWithSheet,
|
||
// Back into the stop he is already on. This is what the red "Active
|
||
// Pickup" strip at the foot of the page used to be — except that one
|
||
// only refetched the queue and never actually opened anything.
|
||
onContinueStop: _continueStop,
|
||
// The whole route on one map. Reads the trip's own ordering into the
|
||
// step numbers so the pins are labelled the way the card lists them,
|
||
// rather than however the map screen would number them itself.
|
||
onViewRoute: trip.stops.isEmpty
|
||
? null
|
||
: () => openScreen(
|
||
context,
|
||
pickups.MultiCustomerMapScreen(
|
||
pickup: trip.stops,
|
||
preservedStepNumbers: {
|
||
for (var i = 0; i < trip.stops.length; i++)
|
||
(trip.stops[i]['orderid'] ?? '').toString(): i + 1,
|
||
},
|
||
),
|
||
swipeToGoBack: false,
|
||
),
|
||
);
|
||
|
||
/// \u2500\u2500 The app bar \u2014 pinned, never scrolls away \u2500\u2500
|
||
///
|
||
/// This used to be a `SliverToBoxAdapter` at the top of the scroll view, so
|
||
/// the rider's identity *and the ON/OFF duty toggle* slid off the screen as
|
||
/// soon as he looked at his second stop. The toggle is the control that
|
||
/// governs everything else on the page \u2014 going off duty stops work being
|
||
/// assigned \u2014 so needing to scroll back up to reach it was the wrong trade.
|
||
/// It is now a sibling above the scroll view, which pins it outright.
|
||
///
|
||
/// Only the identity row is pinned. The shift window that used to share this
|
||
/// white block scrolls with the content now (see [_buildShiftRow]): it is
|
||
/// read-once reference information, and holding it up here would spend ~44
|
||
/// permanent pixels \u2014 on top of the status bar, this bar, the trip tabs and
|
||
/// the bottom nav \u2014 to keep a static time range on screen.
|
||
/// ── The header's data, assembled ──
|
||
///
|
||
/// The header itself is [HomeHeaderDelegate] — a pinned sliver inside the
|
||
/// page's own `CustomScrollView`, not a fixed block above it. This composes
|
||
/// the strings it draws, from the state the page already holds. No new
|
||
/// source, no extra call: `_trips` is the same getter `build` reads and the
|
||
/// duty flags are the same fields the old switch read.
|
||
SliverPersistentHeader _buildHeaderSliver(BuildContext context) {
|
||
final String firstName = _userName.isNotEmpty
|
||
? _userName.split(' ').first
|
||
: 'Rider';
|
||
|
||
final List<Trip> headerTrips = _trips;
|
||
final Trip? current = headerTrips.tripAt(_selectedTripIndex);
|
||
|
||
// The one figure the whole shift is steered by, computed once and shown
|
||
// once. The run card below used to print the same arithmetic again.
|
||
final int total = current?.stopCount ?? 0;
|
||
final int done =
|
||
current
|
||
?.states(acceptedIds: _acceptedIds, rejectedIds: _rejectedIds)
|
||
.where((st) => st.isResolved)
|
||
.length ??
|
||
0;
|
||
final int left = (total - done).clamp(0, total);
|
||
|
||
final String subtitle = total == 0
|
||
? (isOnline ? 'Waiting for work' : 'You are off duty')
|
||
: left == 0
|
||
? 'Run complete'
|
||
: left == 1
|
||
? '1 stop remaining'
|
||
: '$left stops remaining';
|
||
|
||
// `Trip 1 · 17 left · 33/50` — the collapsed line, from the same three
|
||
// figures the expanded tabs draw, so the two states cannot drift.
|
||
final String collapsed = total == 0
|
||
? '${Trip.tabLabel(_selectedTripIndex)} · $subtitle'
|
||
: '${Trip.tabLabel(_selectedTripIndex)} · $left left · $done/$total';
|
||
|
||
return SliverPersistentHeader(
|
||
pinned: true,
|
||
delegate: HomeHeaderDelegate(
|
||
topInset: MediaQuery.paddingOf(context).top,
|
||
expandedContent: HomeHeaderDelegate.expandedContentFor(context),
|
||
greeting: '${_partOfDay()}, $firstName',
|
||
subtitle: subtitle,
|
||
onDuty: isOnline,
|
||
// Unchanged rules, new presentation: duty is locked mid-pickup because
|
||
// going off would strand a customer, and refused while a write is in
|
||
// flight.
|
||
dutyLocked: _hasActivePickup(),
|
||
dutyBusy: _isToggling,
|
||
// The confirmation sheet still owns the decision — see
|
||
// [_confirmOnlineOffline]. Nothing here changes duty by itself.
|
||
onToggleDuty: _confirmOnlineOffline,
|
||
collapsedSummary: collapsed,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// `Good morning` / `Good afternoon` / `Good evening`.
|
||
///
|
||
/// The greeting was `Hi, Rajan` — a word with no information in it. The time
|
||
/// of day costs the same line and tells a rider on a split shift which half
|
||
/// of it he is in.
|
||
String _partOfDay() {
|
||
final hour = DateTime.now().hour;
|
||
if (hour < 12) return 'Good morning';
|
||
if (hour < 17) return 'Good afternoon';
|
||
return 'Good evening';
|
||
}
|
||
|
||
/// ── Duty control ──
|
||
///
|
||
/// The presentation lives in [DutyPill] now, in the header file. What was
|
||
/// here was `AnimatedToggleSwitch.dual` — a 34pt two-position track carrying
|
||
/// the words *On duty* and *Off duty* — which is a settings control, and was
|
||
/// the second loudest object in a header whose subject is the work. See the
|
||
/// pill for the reasoning; the package it used is no longer imported by this
|
||
/// screen.
|
||
///
|
||
/// **None of the duty logic moved.** [_confirmOnlineOffline] still owns the
|
||
/// decision, the confirmation sheet still runs, `_isToggling` still gates a
|
||
/// second press and `_hasActivePickup()` still locks it mid-stop.
|
||
|
||
/// Shown on a trip tab the hub has not filled yet.
|
||
///
|
||
/// The whole thing lives in [TripEmptyState] — including the reasoning for
|
||
/// why it is no longer a bordered card. Keyed on the tab so switching from
|
||
/// one unassigned slot to another restarts the entrance rather than silently
|
||
/// swapping the heading.
|
||
Widget _buildTripEmptyState(int index) {
|
||
return TripEmptyState(
|
||
key: ValueKey('trip-empty-$index'),
|
||
tripLabel: Trip.tabLabel(index),
|
||
offline: !isOnline,
|
||
onGoOnDuty: _confirmOnlineOffline,
|
||
);
|
||
}
|
||
|
||
Widget _buildEmptyState() {
|
||
// Offline riders don't receive bookings, so the empty state must explain
|
||
// *why* it's empty and offer the one action that fixes it — going online.
|
||
final bool offline = !isOnline;
|
||
|
||
// ── Online, nothing assigned: the art illustrates, the type speaks ──
|
||
//
|
||
// The artwork ships with its own headline baked into the pixels — "No
|
||
// Bookings **Available**" with a red accent word, and a marketing line
|
||
// ending in an exclamation mark. Baked type cannot be Poppins, cannot
|
||
// scale with the rider's system text size, and reads as a poster pasted
|
||
// into the app rather than the app talking. So the image is cropped to
|
||
// its illustration — the bottom band where the words live is cut — and
|
||
// the message is set below it in the design system's own voice, exactly
|
||
// as every other empty state on the app does.
|
||
if (!offline) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 28.w),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── The artwork carries its own white, so give it an edge ──
|
||
//
|
||
// Seen on a device: this asset has a white background baked in,
|
||
// and Home's ground is the canvas — so a hard white rectangle
|
||
// sat on grey with visible corners, reading as a failed image
|
||
// rather than an illustration.
|
||
//
|
||
// Rounding it turns the asset's own ground into a deliberate
|
||
// layer-1 surface. Nothing is masked or recoloured; the picture
|
||
// simply stops having square corners on a page that has none.
|
||
SizedBox(
|
||
width: 450.w,
|
||
child: AspectRatio(
|
||
// The illustration occupies the top ~75% of the 3:2 asset;
|
||
// everything below that line is the baked copy being
|
||
// removed. Top-aligned cover crop keeps scooter, rider and
|
||
// pin whole.
|
||
aspectRatio: 1536 / 770,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(
|
||
DesignConstants.radiusXl,
|
||
),
|
||
child: Image.asset(
|
||
'assets/images/no_bookings_available.png',
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.topCenter,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 18.h),
|
||
Text(
|
||
'No bookings yet',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -0.4,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 6.h),
|
||
Text(
|
||
'Stay on duty new bookings appear here the moment '
|
||
'your office assigns you.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 13.5.sp,
|
||
height: 1.45,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
// Offline Lottie in its native colors.
|
||
SizedBox(
|
||
width: 210.w,
|
||
height: 180.w,
|
||
child: Lottie.asset(
|
||
'assets/lotties/no_internet.json',
|
||
fit: BoxFit.contain,
|
||
repeat: true,
|
||
),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
Text(
|
||
"You're Offline",
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w700,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textPrimary,
|
||
),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
Text(
|
||
"Go online to start receiving bookings",
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
SizedBox(height: 18.h),
|
||
ElevatedButton.icon(
|
||
onPressed: _hasActivePickup() ? null : _confirmOnlineOffline,
|
||
icon: Icon(LucideIcons.zap, size: 18.sp),
|
||
label: Text(
|
||
"Go Online",
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w700,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _primaryRed,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
// Mid-size: content-hugging but with a comfortable min width,
|
||
// so it's neither full-width nor a tiny chip. The height is
|
||
// the unscaled [ButtonSizes.compact] rather than `44.h` —
|
||
// ScreenUtil would shrink a scaled 44 below the tap-target
|
||
// floor on a short phone, which is where it matters most.
|
||
minimumSize: Size(150.w, ButtonSizes.compact),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 11.h),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
// ── `_buildBookingCard` is gone ──
|
||
//
|
||
// A ~600-line alternate Home, unreachable: no call site anywhere in the app.
|
||
// It drew a stop as a bordered, shadowed, rounded card with its own status
|
||
// pill, its own accept/reject pair and its own address treatment — a second
|
||
// and contradictory visual system for the same object the route node now
|
||
// draws, sitting in the production file waiting for somebody to wire it back
|
||
// up because it looked finished.
|
||
//
|
||
// Nothing was salvaged from it. Every helper it called is still used by the
|
||
// live composition; it called no logic of its own that the route timeline
|
||
// does not already own.
|
||
|
||
Widget _buildActionButton({
|
||
required IconData icon,
|
||
required Color color,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 38.w,
|
||
height: 38.w,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
child: Icon(icon, color: color, size: 18.sp),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBulkActionButton() {
|
||
final selectedCount = _selectedOrders.values.where((s) => s).length;
|
||
if (selectedCount <= 1) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
color: _primaryRed,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: _primaryRed.withValues(alpha: 0.25),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 6),
|
||
),
|
||
],
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: _hasActivePickup() ? null : _handleBulkAction,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(LucideIcons.zap, color: Colors.white, size: 20.sp),
|
||
SizedBox(width: 8.w),
|
||
Text(
|
||
'${_getBulkButtonLabel()} (${selectedCount})',
|
||
style: TextStyle(
|
||
fontSize: 15.sp,
|
||
fontWeight: FontWeight.w700,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: Colors.white,
|
||
letterSpacing: 0.3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==================== MAIN BUILD METHOD ====================
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
// The day, grouped into the slots the hub assigned — see [_trips]. The tab
|
||
// bar below chooses one of them; [trip] is null when that slot is still
|
||
// empty, which is its own screen and not "no work".
|
||
final List<Trip> trips = _trips;
|
||
final Trip? trip = _selectedTrip;
|
||
final bool hasWork = trips.isNotEmpty;
|
||
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, result) async {
|
||
if (didPop) return;
|
||
bool shouldExit = true;
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final hasLive = prefs.getBool('has_live_pickup') ?? false;
|
||
if (hasLive) {
|
||
final confirm = await showAppDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
title: Text(
|
||
'Pending Pickup',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textPrimary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
content: Text(
|
||
'Are you sure you want to close? There are pickups pending.',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(
|
||
'No',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _textSecondary,
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(
|
||
'Yes',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: _primaryRed,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
shouldExit = confirm == true;
|
||
}
|
||
} catch (_) {}
|
||
if (shouldExit) {
|
||
// Home is the root tab — leaving it backgrounds the app.
|
||
await SystemNavigator.pop();
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: _surfaceColor,
|
||
body: Stack(
|
||
children: [
|
||
// ── One scroll view, and the header is a sliver in it ──
|
||
//
|
||
// It was a `Column`: the header as a fixed first child, the scroll
|
||
// view as the second. That pinned the header by construction and
|
||
// charged its full height — 214pt with the status bar — at every
|
||
// scroll position, which is why the rider's first stop started
|
||
// below the fold.
|
||
//
|
||
// As a pinned sliver it holds the top of the screen the same way,
|
||
// and collapses to 52pt of operational context on the way down.
|
||
// Native `SliverPersistentHeader`; no package was added for it.
|
||
// See [HomeHeaderDelegate].
|
||
AnnotatedRegion<SystemUiOverlayStyle>(
|
||
// ── The status bar belongs to the header ──
|
||
//
|
||
// On a brand ground the clock and the battery have to invert with
|
||
// it, or the top 44pt of the phone is dark glyphs on maroon —
|
||
// which is the one contrast failure a rider cannot work around by
|
||
// tilting the screen.
|
||
value: const SystemUiOverlayStyle(
|
||
statusBarColor: Colors.transparent,
|
||
statusBarIconBrightness: Brightness.light,
|
||
statusBarBrightness: Brightness.dark,
|
||
),
|
||
child: RefreshIndicator(
|
||
onRefresh: _handleRefresh,
|
||
color: ColorConstants.primary,
|
||
// The spinner comes down below the pinned header rather than
|
||
// out from behind it.
|
||
edgeOffset:
|
||
MediaQuery.paddingOf(context).top +
|
||
HomeHeaderDelegate.collapsedContent,
|
||
child: CustomScrollView(
|
||
// ── Physics, and why it is not `Bouncing` ──
|
||
//
|
||
// `BouncingScrollPhysics` forced the iOS rubber-band on every
|
||
// platform, and rubber-band overscroll is unbounded — so a
|
||
// pull dragged the whole page off the top of the screen and
|
||
// held it against a blank gap. Plain
|
||
// `AlwaysScrollableScrollPhysics` lets `applyTo` compose with
|
||
// the platform default (clamping on Android, bouncing on
|
||
// iOS), while "always scrollable" keeps the pull working on a
|
||
// short list.
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
slivers: [
|
||
_buildHeaderSliver(context),
|
||
|
||
// ── The trip tabs, under the header rather than in it ──
|
||
//
|
||
// They govern what the page below shows, so they belong to
|
||
// the page. Inside the header they were the one control on
|
||
// the screen that changes the content, living in the one
|
||
// surface that never does — and the red underline could
|
||
// only be white, because it was already on red.
|
||
//
|
||
// Not pinned. The header keeps `Trip 1 · 17 left · 33/50`
|
||
// when it collapses, which is what a rider scrolling needs;
|
||
// three tabs held permanently would be the same information
|
||
// twice, in the space the work is supposed to be in.
|
||
// The tabs are no longer a sliver of their own. They are
|
||
// the head of the panel that holds the trip, and that panel
|
||
// is built with the work below — see the `else` branch.
|
||
|
||
// ══════════════════════════════════════════════════════════
|
||
// READING ORDER
|
||
//
|
||
// [app bar] fixed ← greeting, duty, 1·2·3
|
||
// trip summary scrolls away (ring, slot, load, metrics)
|
||
// hint line scrolls
|
||
// HUB → stops → HUB scrolls
|
||
// accept bar scrolls
|
||
//
|
||
// Nothing in this scroll view is pinned any more. Everything here
|
||
// is reference material the rider reads once and then works from
|
||
// memory, so all of it is allowed to leave. The one control that
|
||
// governs the whole screen — which trip he is looking at — moved
|
||
// into the header, which is fixed by being the Column's first
|
||
// child rather than by being a sliver. See [TripSelector].
|
||
// ══════════════════════════════════════════════════════════
|
||
|
||
// The trip brief used to sit here, above the tabs. It now sits
|
||
// below them, inside the per-trip block — see the note at its new
|
||
// call site.
|
||
//
|
||
// The standalone shift-window strip that used to sit above it is
|
||
// gone too: it showed the rider's shift while the card below it
|
||
// showed the *trip's* slot under the caption "CURRENT SHIFT", so
|
||
// two different time ranges sat stacked with one wearing the
|
||
// other's name. Both now live inside [TripBriefStrip] as adjacent
|
||
// labelled rows. See that class for the rest of the reasoning.
|
||
|
||
// ── Trip 1 · 2 · 3 ──
|
||
//
|
||
// Gone from here. It was a pinned
|
||
// `SliverPersistentHeader` on the canvas, in a
|
||
// strip of its own between the brand bar and the
|
||
// page — a third surface down the top of the
|
||
// screen whose only job was to hold a control. It
|
||
// is inside the header now, which was already
|
||
// pinned, so the strip, its hairline and its height
|
||
// arithmetic all went with it. See [TripSelector].
|
||
|
||
// The stops, an empty state for an unassigned slot, or — until
|
||
// the first fetch lands — the shape of a route being loaded.
|
||
//
|
||
// One swap holding all of them, so the hand-off between them is
|
||
// a cross-fade instead of one sliver being swapped for another
|
||
// and the screen going briefly blank in between.
|
||
//
|
||
// ── Why the sliver type changes with the state ──
|
||
//
|
||
// With nothing to list, the message *is* the page, and it
|
||
// belongs in the middle of it. A box adapter shrink-wraps its
|
||
// child, so a Center inside one only centres within the
|
||
// artwork's own height — which parked it under the app bar at
|
||
// the top of an otherwise blank screen. [SliverFillRemaining]
|
||
// hands it the rest of the viewport to be centred in, and
|
||
// `hasScrollBody: false` still lets it grow past that on a
|
||
// short phone rather than clipping.
|
||
//
|
||
// A real route keeps the adapter: filling the remainder there
|
||
// would pad every short trip with a screen of dead space above
|
||
// the floating-pill floor.
|
||
if (!hasWork)
|
||
SliverFillRemaining(
|
||
hasScrollBody: false,
|
||
child: SmoothSwap(
|
||
child: !_queuesLoaded
|
||
// The real timeline in grey, not three
|
||
// generic cards — see [HomeRouteSkeleton].
|
||
? const HomeRouteSkeleton(
|
||
key: ValueKey('home-loading'),
|
||
)
|
||
: _fetchFailed
|
||
? Padding(
|
||
key: const ValueKey('home-error'),
|
||
padding: EdgeInsets.symmetric(vertical: 32.h),
|
||
child: ErrorRetry(onRetry: _handleRefresh),
|
||
)
|
||
// ── A day with no work says so ──
|
||
//
|
||
// This rendered `SizedBox.shrink()` —
|
||
// literally nothing — so a rider whose hub
|
||
// had not assigned him anything opened Home
|
||
// to a blank screen and could not tell it
|
||
// apart from a screen that had failed to
|
||
// load. The empty state already existed and
|
||
// had simply been orphaned when the trip tabs
|
||
// came out; it states which of the two this
|
||
// is, and offers the one action that changes
|
||
// it when he is off duty.
|
||
: KeyedSubtree(
|
||
key: const ValueKey('home-empty'),
|
||
child: _buildEmptyState(),
|
||
),
|
||
),
|
||
)
|
||
// ── The panel is the page ──
|
||
//
|
||
// Tabs and trip used to be two slivers: a control in one
|
||
// box, the work it governs in the next, with nothing but
|
||
// adjacency to say they were related. The panel encloses
|
||
// the run now — the selected tab is the top edge of the
|
||
// brand surface the brief, the route and the action bar all
|
||
// sit inside, so which trip is on screen is said once, by
|
||
// the shape holding it.
|
||
//
|
||
// Still not pinned. The header keeps
|
||
// `Trip 1 · 17 left · 33/50` when it collapses, which is
|
||
// what a rider scrolling needs; a tab strip held
|
||
// permanently would be the same information twice, in the
|
||
// space the work is supposed to be in.
|
||
// ── The tabs are attached to the trip's figures ──
|
||
//
|
||
// Tabs and brief used to be two boxes: a control in one
|
||
// sliver, the figures it governs in the next, with nothing
|
||
// but adjacency to say they were related. They are one
|
||
// panel now — the selected tab is the top edge of the glass
|
||
// the brief sits on, so which trip is on screen is said
|
||
// once, by the shape holding it.
|
||
//
|
||
// The route and the action bar stay outside it, on the
|
||
// canvas with their own white panel. A version of this put
|
||
// the whole page inside the panel and it was too much: the
|
||
// ground a rider reads a route off should be the quietest
|
||
// thing on the screen, not the loudest.
|
||
//
|
||
// Still not pinned. The header keeps
|
||
// `Trip 1 · 17 left · 33/50` when it collapses, which is
|
||
// what a rider scrolling needs; a tab strip held
|
||
// permanently would be the same information twice, in the
|
||
// space the work is supposed to be in.
|
||
else ...[
|
||
SliverToBoxAdapter(
|
||
// Air above, and the panel takes the same gutter as
|
||
// every other layer-1 surface on Home. The inset inside
|
||
// it is [TripSelector.contentInset], so the brief's copy
|
||
// lands on the same vertical as `TODAY'S RUN` below.
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
MilerSurface.panelGutter,
|
||
12.h,
|
||
MilerSurface.panelGutter,
|
||
0,
|
||
),
|
||
child: TripSelector(
|
||
trips: trips,
|
||
selectedIndex: _selectedTripIndex,
|
||
acceptedIds: _acceptedIds,
|
||
rejectedIds: _rejectedIds,
|
||
// Selection is per-trip, so the ticks do not travel
|
||
// with the rider between trips — a tick made on
|
||
// Trip 1 must not be counted by the bar while Trip 2
|
||
// is on screen. See [_selectedPendingIds].
|
||
onSelect: (index) {
|
||
// His choice from here on, even if it is an empty
|
||
// slot — see [_settleTripTab].
|
||
_tripTabChosen = true;
|
||
if (index == _selectedTripIndex) return;
|
||
setState(() {
|
||
_selectedTripIndex = index;
|
||
_selectedStopIds.clear();
|
||
});
|
||
},
|
||
// ── An unfilled slot gets the same panel ──
|
||
//
|
||
// It used to get a different one: a line of its own
|
||
// reading `Not assigned yet`, which made the panel
|
||
// a 32pt stub under a tab twice its height, and
|
||
// changed the shape of the screen depending on
|
||
// whether the hub had got to this slot yet.
|
||
//
|
||
// The brief of an empty slot is a real brief — it
|
||
// is just all zeroes. `0 stops left`, an empty
|
||
// track, and an em dash under every figure that has
|
||
// nothing to report. So the panel is the same panel
|
||
// at the same height, and when the hub assigns the
|
||
// trip the figures fill in where the dashes were
|
||
// instead of the page changing shape around the
|
||
// rider.
|
||
body: SmoothSwap(
|
||
child: Reveal(
|
||
// Keyed on the trip, so switching tabs plays
|
||
// the entrance once and the 5s poll never
|
||
// replays it. The empty slot keys on its own
|
||
// index for the same reason.
|
||
key: ValueKey(
|
||
'brief_${trip?.id ?? 'unassigned-$_selectedTripIndex'}',
|
||
),
|
||
// The panel above is the surface and it is the
|
||
// brand bar's red, so the brief drops its own
|
||
// card and inverts its ink.
|
||
child: _tripCardFor(
|
||
trip ?? _unassignedTrip(),
|
||
).buildSummary(onBrand: true),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// The route and the action bar, on the canvas.
|
||
SliverToBoxAdapter(
|
||
child: SmoothSwap(
|
||
// ── A slot the hub has not filled ──
|
||
//
|
||
// The day has work, just not on this tab. Says so by
|
||
// name rather than reusing the whole-day empty state,
|
||
// which would claim he has nothing to do while another
|
||
// trip is running.
|
||
child: trip == null
|
||
? KeyedSubtree(
|
||
key: ValueKey(
|
||
'trip-empty-$_selectedTripIndex',
|
||
),
|
||
child: _buildTripEmptyState(
|
||
_selectedTripIndex,
|
||
),
|
||
)
|
||
: Reveal(
|
||
key: ValueKey('reveal_${trip.id}'),
|
||
child: Column(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
// The "one thing to do now" head that
|
||
// used to sit here is gone. It drew the
|
||
// current place, its load and a
|
||
// full-width **Navigate to pickup** — all
|
||
// of which the timeline's own group node
|
||
// below already carries, button included.
|
||
// Two identical red buttons for one
|
||
// destination was the single largest
|
||
// thing on this screen; the node that owns
|
||
// the journey owns the action.
|
||
_tripCardFor(trip).buildStops(),
|
||
_tripCardFor(trip).buildActionBar(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
|
||
// ── Room for everything that floats over this list ──
|
||
//
|
||
// 80 was measured against the accepted pill alone. Two
|
||
// more things pin to the same corner — the selection
|
||
// bar and the live-pickup banner — and with a banner up
|
||
// the last stop card was covered by it with no way to
|
||
// scroll clear, which is what a rider reported as "the
|
||
// card is cut off".
|
||
//
|
||
// The floor now clears the nav bar plus the tallest
|
||
// stack that can sit on it, so the end of the route is
|
||
// always reachable.
|
||
// ── Measured, not assumed ──
|
||
//
|
||
// This was a flat `80.h`. The two surfaces that can
|
||
// occupy the bottom slot now declare their own
|
||
// heights, and the floor reserves the taller of the
|
||
// two plus the device inset — so the last route row
|
||
// clears whichever one is showing, at any text
|
||
// scale, without anybody re-measuring by eye.
|
||
SliverToBoxAdapter(
|
||
child: SizedBox(
|
||
height:
|
||
(SelectionBar.maxHeight > AcceptedPill.maxHeight
|
||
? SelectionBar.maxHeight
|
||
: AcceptedPill.maxHeight) +
|
||
BottomPage.bottomInset(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── The bottom overlay ──
|
||
//
|
||
// Only one thing floats over the foot of Home now. The red "Active
|
||
// Pickup" strip that used to share this column is gone: it was a
|
||
// second object describing a stop the list below was hiding, it
|
||
// covered the last card, and its tap did nothing but refetch. The
|
||
// live stop is a row in the trip now, marked LIVE and carrying its
|
||
// own way back in — see `TripCard._showsOnHome`.
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
// ── The command surface arrives, it does not appear ──
|
||
//
|
||
// Ticking a row swaps one floating object for another in the same
|
||
// slot, and swapping them in a single frame reads as the page
|
||
// flickering rather than as a mode changing. A short rise-and-fade
|
||
// — the app's own state duration, no bounce — is what says "this
|
||
// came up because of what you just did".
|
||
child: AnimatedSwitcher(
|
||
duration: DesignConstants.motionState,
|
||
switchInCurve: Curves.easeOutCubic,
|
||
switchOutCurve: Curves.easeInCubic,
|
||
transitionBuilder: (child, anim) => FadeTransition(
|
||
opacity: anim,
|
||
child: SlideTransition(
|
||
position: Tween<Offset>(
|
||
begin: const Offset(0, 0.35),
|
||
end: Offset.zero,
|
||
).animate(anim),
|
||
child: child,
|
||
),
|
||
),
|
||
child: Column(
|
||
key: ValueKey(_selectionCount > 0),
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_selectionCount > 0)
|
||
Builder(
|
||
builder: (context) {
|
||
final action = _selectionAction();
|
||
return SelectionBar(
|
||
count: _selectionCount,
|
||
action: action,
|
||
busy:
|
||
_acceptingTripId == 'selection' ||
|
||
_busyStopIds.isNotEmpty,
|
||
onClear: () =>
|
||
setState(() => _selectedStopIds = <String>{}),
|
||
onAccept: _acceptingTripId != null
|
||
? null
|
||
: () => _acceptSelectedStops(
|
||
_selectedPendingIds().toList(),
|
||
),
|
||
// Arrived / picked up run against the whole ticked
|
||
// set in one slide — see [_advanceSelection].
|
||
onAdvance: _advanceSelection,
|
||
// A stop already taken on cannot be declined from
|
||
// here, so Reject leaves with the accept rung.
|
||
onReject:
|
||
action != SelectionAction.accept ||
|
||
_acceptingTripId != null
|
||
? null
|
||
: () => _rejectSelectedStops(
|
||
_selectedPendingIds().toList(),
|
||
),
|
||
);
|
||
},
|
||
)
|
||
// ── The hinge is not here ──
|
||
//
|
||
// START DELIVERY used to sit in this slot, and it was in the
|
||
// wrong place. Home is the *collection* workspace: a stop
|
||
// leaves it the moment it is in the rider's hands. So the
|
||
// control that starts the round lived on a screen the round's
|
||
// orders had already left — and the count it showed was the
|
||
// only trace of them anywhere in the app.
|
||
//
|
||
// It belongs where the load is, which is the deliveries tab.
|
||
// See the bar at the foot of `pickups.dart`.
|
||
else
|
||
// Only when nothing is ticked. The two share this slot on
|
||
// purpose — see [AcceptedPill] — and a live selection is
|
||
// the more urgent of the two.
|
||
// ── No SafeArea here ──
|
||
//
|
||
// [BottomPage.bottomInset] already includes
|
||
// `MediaQuery.padding.bottom`, so wrapping the pill in a
|
||
// `SafeArea` added the gesture bar's inset a second time
|
||
// and floated it ~34pt clear of the nav on the phones
|
||
// that have one. The inset is owned in one place; a
|
||
// second opinion about it is what makes a floating
|
||
// control drift between devices.
|
||
AcceptedPill(
|
||
// ── Derived, not counted twice ──
|
||
//
|
||
// This read `_acceptedStops.length` — the length of a
|
||
// persisted list of accepted bookings. That store is
|
||
// written on accept and cleaned on completion, so any
|
||
// stop that left the day some other way (the hub
|
||
// pulling it, a reject that raced a refetch, a run
|
||
// that simply ended) stayed in it, and the pill
|
||
// counted work the rider no longer had. It is a
|
||
// second source of truth for a number the trip
|
||
// already knows.
|
||
//
|
||
// The trip is that source: today's stops, resolved
|
||
// against the same accepted/rejected sets every other
|
||
// surface on this screen reads. Accepting two shows
|
||
// two; rejecting one drops it in the same frame,
|
||
// because the set it is derived from changed.
|
||
count: _acceptedWaitingCount,
|
||
// Moves the shell's IndexedStack rather than
|
||
// rebuilding it, so Bookings keeps its state and the
|
||
// jump lands.
|
||
onTap: () => BottomPage.goToTab(1),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// The status-bar scrim that used to sit here is gone. It was a white
|
||
// strip pinned over the status bar, and it existed for exactly one
|
||
// reason: the header scrolled, so content slid up under the clock and
|
||
// battery icons and made them unreadable. Now that the app bar is
|
||
// pinned — white, and already padded by `MediaQuery.padding.top` —
|
||
// nothing can reach that strip, and a second white rectangle painted
|
||
// over a white bar every frame is just cost.
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== BOTTOM SHEETS ====================
|
||
|
||
// The "Trip Map" sheet (`_showmapDetailsSheet`) that lived here is deleted:
|
||
// zero callers — its job (map + booking facts for one stop) has belonged to
|
||
// [StopDetailSheet] since the timeline rework, and it still carried the
|
||
// pre-redesign idiom: red-tinted bordered boxes, a 36pt cancel icon, and
|
||
// 'Booking: #id' as the subtitle of a sheet a rider opens to see a map.
|
||
|
||
Widget _buildDetailRow(String label, String value) {
|
||
final Color primaryRed = ColorConstants.primary;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
// crossAxisAlignment.start keeps the label top-aligned when a long value
|
||
// (e.g. a full pickup address) wraps onto several lines.
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: ColorConstants.onSurfaceVariant,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// Expanded + soft-wrap: the value takes the remaining width and flows
|
||
// onto the next line instead of overflowing off the right edge.
|
||
Expanded(
|
||
child: Text(
|
||
value,
|
||
textAlign: TextAlign.right,
|
||
softWrap: true,
|
||
style: TextStyle(
|
||
color:
|
||
value.contains('Arrived') ||
|
||
value.contains('Picked') ||
|
||
value.contains('Accepted')
|
||
? primaryRed
|
||
: ColorConstants.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14.sp,
|
||
fontFamily: FontConstants.fontFamily,
|
||
height: 1.35,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showProductDetailsSheet(
|
||
BuildContext context, [
|
||
Map<String, dynamic>? item,
|
||
]) {
|
||
final data = item ?? const <String, dynamic>{};
|
||
|
||
String val(List<String> keys) {
|
||
for (final k in keys) {
|
||
final v = data[k];
|
||
if (v != null && v.toString().trim().isNotEmpty && v.toString() != '0') {
|
||
return v.toString().trim();
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// Build the rows that actually have data.
|
||
// What the parcel IS leads; the reference is last — it is the one line the
|
||
// rider never decides anything on, read only when the office asks for it.
|
||
final rows = <MapEntry<String, String>>[
|
||
MapEntry(
|
||
'Product',
|
||
val(['description', 'productname', 'product', 'tenantname']),
|
||
),
|
||
MapEntry('Quantity', val(['Quantity', 'quantity'])),
|
||
MapEntry('Weight', () {
|
||
final w = val(['weight', 'Weight']);
|
||
return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg');
|
||
}()),
|
||
MapEntry('Amount', () {
|
||
final a = val(['collectionamt', 'pickupamt']);
|
||
return a.isEmpty ? '' : '₹$a';
|
||
}()),
|
||
MapEntry('Pickup point', val(['pickupcustomer', 'tenantname'])),
|
||
MapEntry('Address', val(['pickupaddress', 'PickupAddress'])),
|
||
MapEntry('Contact', val(['pickupcontactno'])),
|
||
MapEntry('Notes', val(['notes', 'Notes'])),
|
||
MapEntry('Order ID', val(['orderid', 'OrderId'])),
|
||
].where((e) => e.value.isNotEmpty).toList();
|
||
|
||
// Through the kit. The old sheet drew "Product Details" beside a 32pt red
|
||
// cancel IconButton (the handle and the barrier already dismiss), and boxed
|
||
// the rows in a bordered panel — a container inside a container, on the one
|
||
// page family that has none. The rows are a plain ruled list now, and an
|
||
// empty payload is one quiet sentence rather than a 40pt-padded red box.
|
||
showMilerSheet<void>(
|
||
context,
|
||
builder: (context) => MilerSheetScaffold(
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const MilerSheetHeader(
|
||
title: 'Product details',
|
||
icon: LucideIcons.package,
|
||
),
|
||
SizedBox(height: 14.h),
|
||
if (rows.isEmpty)
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 1.h),
|
||
child: Text(
|
||
'No product details on this booking.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
)
|
||
else
|
||
for (int i = 0; i < rows.length; i++) ...[
|
||
_buildDetailRow(rows[i].key, rows[i].value),
|
||
if (i != rows.length - 1)
|
||
Divider(height: 12, color: ColorConstants.borderSubtle),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE ARRIVED PRECEDENCE RULE
|
||
///
|
||
/// Whether the local ARRIVED record is allowed to speak for a row the server
|
||
/// has already described.
|
||
///
|
||
/// ── A whitelist, because the blacklist leaked ──
|
||
///
|
||
/// This was three exclusions — not complete, not cancelled, not rejected —
|
||
/// and `isWorkComplete` is line-dependent: on a milk run it means *delivered
|
||
/// or cancelled* only. So a server row reporting `picked`, `outForDelivery`
|
||
/// or `deliveryArrived` passed all three tests and the local record wrote
|
||
/// `arrived` straight over it. That is the one thing this store must never
|
||
/// do — it would walk a stop the hub knows is on the road back to a rung the
|
||
/// rider left an hour ago.
|
||
///
|
||
/// The rule inverted: the local record may speak **only** where the server
|
||
/// has nothing further along to say. Everything the server does say wins,
|
||
/// including `arrived` itself, where writing it again is a no-op.
|
||
///
|
||
/// This is the whole of the precedence contract, in one place, so it can be
|
||
/// asserted directly rather than inferred from three negations. **Delete it
|
||
/// with the store** — see [getArrivedOrderIds] — once `reached` persists.
|
||
abstract final class HomepageArrivalPrecedence {
|
||
static bool localArrivalMayShow(StopStatus serverSaid) =>
|
||
serverSaid.isPending ||
|
||
serverSaid == StopStatus.accepted ||
|
||
serverSaid == StopStatus.arrived ||
|
||
// A row whose status the app could not parse is a row the server has
|
||
// said nothing usable about, which is exactly the gap this store fills.
|
||
serverSaid == StopStatus.unknown;
|
||
}
|