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

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

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

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

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

892 lines
46 KiB
Dart

part of 'deliveries.dart';
// -------------------------------------------------------------------------
// SCREEN 1: DELIVERY MAP PREVIEW (Shows route, has "Start" button)
// -------------------------------------------------------------------------
class _DeliveryMapScreen extends StatefulWidget {
final Map<String, dynamic> delivery;
final _MyDeliveriesState? parentState;
const _DeliveryMapScreen({required this.delivery, this.parentState});
@override
State<_DeliveryMapScreen> createState() => _DeliveryMapScreenState();
}
class _DeliveryMapScreenState extends State<_DeliveryMapScreen> {
DoormileMapController? _mapController;
late final LatLng _pickupLocation;
late final LatLng _dropLocation;
List<DoormileMapMarker> _markers = const [];
List<DoormileMapPolyline> _polylines = const [];
bool _isNavigating = false; // Prevent multiple clicks
@override
void initState() {
super.initState();
_resolveLocationsFromDelivery();
_setMarkers();
_createRealRoute();
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
void _resolveLocationsFromDelivery() {
final d = widget.delivery;
final double pickLat = _parseD(d['pickuplat'] ?? d['PickupLat']);
final double pickLon = _parseD(d['pickuplon'] ?? d['PickupLon']);
final double dropLat = _parseD(
d['droplat'] ?? d['DropLat'] ?? d['deliverylat'],
);
final double dropLon = _parseD(
d['droplon'] ?? d['DropLon'] ?? d['deliverylong'],
);
final double riderLat = _parseD(d['riderslat']);
final double riderLon = _parseD(d['riderslon']);
final bool hasPickup = pickLat != 0 && pickLon != 0;
final bool hasDrop = dropLat != 0 && dropLon != 0;
final LatLng pickup = hasPickup
? LatLng(pickLat, pickLon)
: (riderLat != 0 && riderLon != 0
? LatLng(riderLat, riderLon)
: const LatLng(10.998356, 76.977596));
final LatLng drop = hasDrop
? LatLng(dropLat, dropLon)
: const LatLng(11.004556, 76.967696);
_pickupLocation = pickup;
_dropLocation = drop;
}
Future<void> _setMarkers() async {
final pickupIcon = await MapMarkerIcons.pin(Colors.red);
final dropIcon = await MapMarkerIcons.pin(Colors.green);
if (!mounted) return;
setState(() {
_markers = [
DoormileMapMarker(
id: 'pickup',
position: _pickupLocation,
icon: pickupIcon,
),
DoormileMapMarker(id: 'drop', position: _dropLocation, icon: dropIcon),
];
});
}
Future<void> _createRealRoute() async {
final route = await RoutingService.getRoute(
origin: _pickupLocation,
destination: _dropLocation,
);
if (!mounted) return;
// Fall back to a straight line so the screen still shows the connection
// when routing is unavailable.
final points = (route != null && route.points.isNotEmpty)
? route.points
: <LatLng>[_pickupLocation, _dropLocation];
if (route == null) debugPrint('[MAP_PREVIEW] No route found');
setState(() {
_polylines = [
DoormileMapPolyline(
id: 'real_route',
points: points,
color: ColorConstants.primaryColor,
width: 6,
),
];
});
_fitMapToRoute();
}
Future<void> _fitMapToRoute() async {
final controller = _mapController;
if (!mounted || controller == null) return;
await controller.fitBounds(
LatLngBounds(
southwest: LatLng(
math.min(_pickupLocation.latitude, _dropLocation.latitude),
math.min(_pickupLocation.longitude, _dropLocation.longitude),
),
northeast: LatLng(
math.max(_pickupLocation.latitude, _dropLocation.latitude),
math.max(_pickupLocation.longitude, _dropLocation.longitude),
),
),
padding: 80,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SizedBox.expand(
child: DoormileMap(
initialCameraPosition: CameraPosition(
target: _pickupLocation,
zoom: 14.5,
),
markers: _markers,
polylines: _polylines,
onMapReady: (controller) {
_mapController = controller;
_fitMapToRoute();
},
),
),
Positioned(
top: 50,
left: 16,
child: CircleAvatar(
backgroundColor: Colors.white,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.pop(context),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 8,
offset: Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: LayoutBuilder(
builder: (ctx, constraints) {
final double maxSheetHeight =
MediaQuery.of(ctx).size.height * 0.35;
// ignore: unused_local_variable
final double allowedHeight = math.min(
constraints.maxHeight,
maxSheetHeight,
);
return ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Customer Details',
style: TextStyle(
fontSize: FontConstants.xxLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
InkWell(
onTap: () async {
// Just launch dialer; PiP is handled only from navigation screen
final phone =
(widget.delivery['deliverycontactno'] ?? '')
.toString();
final bool success = await launchPhoneDialer(
phone.isNotEmpty ? phone : '9876543210',
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not launch dialer'),
),
);
}
},
child: Image.asset(
'assets/images/phone-call .png',
height: 27,
width: 27,
errorBuilder: (c, e, s) => const Icon(
Icons.phone,
size: 27,
color: Colors.green,
),
),
),
],
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 8),
_buildCustomerInfo(),
const SizedBox(height: 20),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isNavigating
? null
: () async {
// Prevent multiple clicks
if (_isNavigating ||
!mounted ||
!context.mounted)
return;
setState(() {
_isNavigating = true;
});
try {
// Capture screen size before navigation
final screenSize =
MediaQuery.of(context).size *
MediaQuery.of(context).devicePixelRatio;
final dc = Get.put(
DeliveriesController(),
permanent: true,
);
final d = widget.delivery;
final int deliveryId =
int.tryParse(
'${d['deliveryid'] ?? d['DeliveryId'] ?? 0}',
) ??
0;
final String orderId =
(d['orderid'] ?? d['OrderId'] ?? '')
.toString();
final int orderHeaderId =
int.tryParse(
'${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}',
) ??
0;
// Save ridertime start at the moment navigation is started
try {
if (deliveryId > 0) {
final prefs =
await SharedPreferences.getInstance();
// 1) Save rider time start (existing behaviour)
await prefs.setString(
'ridertime_start_$deliveryId',
DateTime.now().toIso8601String(),
);
// 2) Save ETA end time for this order so PiP timer can resume correctly
final rawEta = d['eta'];
int etaMinutes = 0;
if (rawEta != null) {
etaMinutes =
int.tryParse(rawEta.toString()) ??
0;
}
if (etaMinutes > 0) {
final now = DateTime.now();
final endTime =
now
.add(
Duration(
minutes: etaMinutes,
),
)
.millisecondsSinceEpoch ~/
1000; // store seconds
await prefs.setInt(
'eta_endtime_$orderId',
endTime,
);
debugPrint(
'[ACTIVE][ETA] Saved eta_endtime_$orderId -> $endTime (eta=$etaMinutes min)',
);
}
debugPrint(
'[ACTIVE] Saved ridertime_start for deliveryId=$deliveryId',
);
}
} catch (e) {
debugPrint(
'[ACTIVE] Error saving ridertime_start: $e',
);
}
final parentState =
widget.parentState ??
context
.findAncestorStateOfType<
_MyDeliveriesState
>();
// ✅ CRITICAL: Check active delivery BEFORE navigation
if (parentState != null) {
final currentOrderId =
(d['orderid'] ?? d['OrderId'] ?? '')
.toString();
final activeOrderIds = parentState
._activeDeliveries
.map(
(del) =>
(del['orderid'] ??
del['OrderId'] ??
'')
.toString(),
)
.where((id) => id.isNotEmpty)
.toSet();
// Block if there's a different active delivery
if (parentState
._activeDeliveries
.isNotEmpty &&
!activeOrderIds.contains(
currentOrderId,
)) {
if (mounted) {
setState(() {
_isNavigating = false;
});
showDialog(
context: context,
barrierDismissible: true,
builder: (BuildContext dialogContext) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(
24,
),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(
20,
),
gradient: LinearGradient(
begin: Alignment.topLeft,
end:
Alignment.bottomRight,
colors: [
Colors.orange.shade400,
Colors.red.shade500,
],
),
),
child: Column(
mainAxisSize:
MainAxisSize.min,
children: [
// Icon
Container(
padding:
const EdgeInsets.all(
16,
),
decoration:
BoxDecoration(
color: Colors
.white
.withOpacity(
0.2,
),
shape: BoxShape
.circle,
),
child: const Icon(
Icons.warning_rounded,
color: Colors.white,
size: 48,
),
),
const SizedBox(
height: 20,
),
// Title
Text(
'Active Delivery in Progress',
textAlign:
TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize:
FontConstants.xxLarge(
context,
),
fontWeight:
FontWeight.bold,
fontFamily:
FontConstants
.fontFamily,
),
),
const SizedBox(
height: 12,
),
// Message
Text(
'Please complete your active delivery first before starting another delivery.',
textAlign:
TextAlign.center,
style: TextStyle(
color: Colors.white
.withOpacity(
0.95,
),
fontSize:
FontConstants.medium(
context,
),
height: 1.4,
fontFamily:
FontConstants
.fontFamily,
),
),
const SizedBox(
height: 24,
),
// Action Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(
dialogContext,
).pop();
// Navigate to active delivery
if (parentState
._activeDeliveries
.isNotEmpty) {
parentState
.startDelivery(
parentState
._activeDeliveries
.first,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor:
Colors.white,
foregroundColor:
Colors
.red
.shade600,
padding:
const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(
12,
),
),
elevation: 2,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment
.center,
children: [
const Icon(
Icons
.two_wheeler,
size: 22,
),
const SizedBox(
width: 8,
),
Text(
'View Active Delivery',
style: TextStyle(
fontSize:
FontConstants.regular(
context,
),
fontWeight:
FontWeight
.bold,
fontFamily:
FontConstants
.fontFamily,
),
),
],
),
),
),
const SizedBox(
height: 12,
),
// Close Button
TextButton(
onPressed: () {
Navigator.of(
dialogContext,
).pop();
},
child: Text(
'Close',
style: TextStyle(
color: Colors.white
.withOpacity(
0.9,
),
fontSize: 15,
fontWeight:
FontWeight.w500,
fontFamily:
FontConstants
.fontFamily,
),
),
),
],
),
),
);
},
);
}
return; // Return early to prevent starting another delivery
}
}
if (deliveryId > 0 && orderId.isNotEmpty) {
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position =
await Geolocator.getCurrentPosition(
locationSettings:
const LocationSettings(
accuracy:
LocationAccuracy.medium,
timeLimit: Duration(
seconds: 3,
),
),
).timeout(
const Duration(seconds: 3),
);
riderLatStr = position.latitude
.toStringAsFixed(6);
riderLngStr = position.longitude
.toStringAsFixed(6);
} catch (e) {
debugPrint(
'[ACTIVE] Error getting rider location: $e',
);
try {
final lastPos =
await Geolocator.getLastKnownPosition();
if (lastPos != null) {
riderLatStr = lastPos.latitude
.toStringAsFixed(6);
riderLngStr = lastPos.longitude
.toStringAsFixed(6);
}
} catch (_) {}
}
// ✅ CRITICAL: Navigate FIRST, then handle status updates
if (!mounted || !context.mounted) {
setState(() {
_isNavigating = false;
});
return;
}
// Navigate immediately - this must happen
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
_RiderNavigationScreen(
delivery: widget.delivery,
parentState: widget.parentState,
),
),
);
// Reset navigation state after navigation completes
setState(() {
_isNavigating = false;
});
// Continue with status updates and PiP in background
debugPrint(
'[ACTIVE] Updating status for deliveryId=$deliveryId orderId=$orderId lat=$riderLatStr lng=$riderLngStr',
);
final ok = await dc.updateActiveStatus(
deliveryId: deliveryId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
orderId: orderId,
);
debugPrint(
'[ACTIVE] Status update result: $ok',
);
// ✅ CRITICAL: ENFORCE PiP when delivery becomes active (compulsory)
if (ok && !dc.isPipEnabled.value) {
try {
debugPrint(
'[ACTIVE] Delivery is now active - Enforcing PiP mode',
);
final floating = Floating();
const rational = Rational.landscape();
final height =
(screenSize.height * 0.5).toInt();
final width = (screenSize.width * 0.9)
.toInt();
final arguments = ImmediatePiP(
aspectRatio: rational,
sourceRectHint: math.Rectangle<int>(
((screenSize.width - width) ~/ 2)
.toInt(),
((screenSize.height - height) ~/
2)
.toInt(),
width,
height,
),
);
await floating.enable(arguments);
dc.isPipEnabled.value = true;
debugPrint(
'[ACTIVE] PiP enabled successfully',
);
// Also try method channel as backup
try {
const channel = MethodChannel(
'doormile/pip',
);
await channel.invokeMethod<bool>(
'enterPip',
);
} catch (_) {}
} catch (e) {
debugPrint(
'[ACTIVE] Error enabling PiP: $e',
);
}
}
if (parentState != null) {
final previousActive =
parentState._activeDeliveryOrderId;
if (previousActive != null &&
previousActive != orderId) {
parentState._stopDeliveryPosting(
previousActive,
);
}
parentState._activeDeliveryOrderId =
orderId;
d['orderstatus'] = 'active';
final orderKey = parentState
._getOrderKey(d);
if (parentState._skippedOrdersCache
.containsKey(orderKey)) {
parentState._skippedOrdersCache
.remove(orderKey);
parentState._skippedOrderTimestamps
.remove(orderKey);
await parentState
._saveSkippedOrdersCache();
debugPrint(
'[ACTIVE] Removed from skipped cache (resumed): $orderKey',
);
}
await parentState._startDeliveryPosting(
d,
);
try {
final prefs =
await SharedPreferences.getInstance();
await prefs.setString(
'active_delivery_order_id',
orderId,
);
} catch (e) {
debugPrint(
'[ACTIVE] Error saving active delivery ID: $e',
);
}
// ignore: invalid_use_of_protected_member
parentState.setState(() {});
}
} else {
debugPrint(
'[ACTIVE] Invalid deliveryId: $deliveryId or orderId: $orderId',
);
setState(() {
_isNavigating = false;
});
}
} catch (e) {
debugPrint(
'[ACTIVE] Error in navigation flow: $e',
);
if (mounted) {
setState(() {
_isNavigating = false;
});
}
}
},
child: _isNavigating
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
const SizedBox(width: 12),
Text(
'Starting...',
style: TextStyle(
fontSize: FontConstants.xLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.white,
),
),
],
)
: Text(
'Start Navigation',
style: TextStyle(
fontSize: FontConstants.xLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.white,
),
),
),
],
);
},
),
),
),
),
],
),
);
}
Widget _buildCustomerInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
'Name:',
(widget.delivery['deliverycustomer'] ?? 'Customer').toString(),
),
const SizedBox(height: 10),
_buildInfoRow(
'Address:',
(widget.delivery['deliveryaddress'] ?? 'Address not available')
.toString(),
isExpanded: true,
),
const SizedBox(height: 10),
_buildInfoRow(
'Order ID:',
'#${(widget.delivery['orderid'] ?? '').toString()}',
),
const SizedBox(height: 10),
_buildInfoRow(
'Distance:',
'${(widget.delivery['kms'] ?? '0').toString()} km',
valueColor: Colors.red,
),
],
);
}
Widget _buildInfoRow(
String label,
String value, {
bool isExpanded = false,
Color? valueColor,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: FontConstants.xLarge(context),
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(width: 15),
isExpanded
? Expanded(
child: Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: FontConstants.large(context),
fontFamily: FontConstants.fontFamily,
color: valueColor ?? Colors.black87,
),
),
)
: Text(
value,
style: TextStyle(
fontSize: FontConstants.large(context),
fontFamily: FontConstants.fontFamily,
color: valueColor ?? Colors.black87,
),
),
],
);
}
}