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