first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View File

@@ -0,0 +1,878 @@
part of 'pickups.dart';
class _StepPoint {
final int step;
final LatLng position;
_StepPoint(this.step, this.position);
}
// -------------------------------------------------------------------------
// MULTI CUSTOMER MAP (Shows all pickup with real step numbers)
// -------------------------------------------------------------------------
class MultiCustomerMapScreen extends StatefulWidget {
final List<Map<String, dynamic>> pickup;
final Map<String, int> preservedStepNumbers;
const MultiCustomerMapScreen({
super.key,
required this.pickup,
this.preservedStepNumbers = const {},
});
@override
State<MultiCustomerMapScreen> createState() => _MultiCustomerMapScreenState();
}
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
with SingleTickerProviderStateMixin {
final MapController mapController = MapController();
late final MilerMapCamera _camera;
List<Marker> markers = <Marker>[];
/// The route as known, faint, underneath.
List<Polyline> casingLines = <Polyline>[];
/// The bright line drawing itself in over the casing. Named rather than kept
/// in a set keyed by 'route_progress': `flutter_map` draws a list in order, so
/// the order is the z-order and the id had nothing left to do.
Polyline? progressLine;
List<Polyline> get routeLayers => <Polyline>[
...casingLines,
if (progressLine != null) progressLine!,
];
/// Bumped whenever a route layer changes mid-animation, so only the map
/// is rebuilt rather than the whole screen.
final ValueNotifier<int> _mapRepaint = ValueNotifier<int>(0);
LatLng? currentLocation;
bool _isLoading = true;
bool _mapReady = false;
final Map<String, Map<String, dynamic>> _pickupMap = {};
/// Every routed point, hub to last stop, in riding order.
///
/// The segments are fetched one leg at a time, but the *animation* has to run
/// across the whole route — drawing each leg in turn would restart at every
/// stop and read as five separate lines rather than one journey.
final List<LatLng> _fullRoute = [];
late final AnimationController _routeAnim;
@override
void initState() {
super.initState();
// Long enough to read as a route being traced rather than a line appearing;
// short enough that the rider is not waiting on it to see his stops.
_camera = MilerMapCamera(controller: mapController, vsync: this);
_routeAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..addListener(_onRouteTick);
if (widget.pickup.isNotEmpty) {
final firstPickup = widget.pickup.first;
final dropLat = _parseD(
firstPickup['droplat'] ?? firstPickup['pickuplat'] ?? 0,
);
final dropLon = _parseD(
firstPickup['droplon'] ?? firstPickup['pickuplong'] ?? 0,
);
if (dropLat != 0 && dropLon != 0) {
currentLocation = LatLng(dropLat, dropLon);
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
_loadMapData();
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
int _getStepNumber(Map<String, dynamic> Booking) {
final dynamic raw = Booking['step'] ?? Booking['Step'];
final int step = raw == null
? 0
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
return step;
}
String _getOrderKey(Map<String, dynamic> Booking) {
final pickupId = (Booking['pickupid'] ?? '').toString();
final orderId = (Booking['orderid'] ?? '').toString();
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
}
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
final orderKey = _getOrderKey(Booking);
if (widget.preservedStepNumbers.containsKey(orderKey)) {
return widget.preservedStepNumbers[orderKey]!;
}
final currentStep = _getStepNumber(Booking);
return currentStep;
}
Future<void> _loadMapData() async {
try {
if (mounted) {
setState(() {
_isLoading = false;
});
}
_getCurrentLocation().then((_) {
if (mounted) {
_createPickupMarkers();
}
});
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error loading map data: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _getCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
}
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
distanceFilter: 0,
timeLimit: Duration(seconds: 5),
),
);
} catch (e) {
debugPrint(
'[CUSTOMER_MAP] Timeout getting location, using last known: $e',
);
position = await Geolocator.getLastKnownPosition();
}
final safePosition = position;
if (safePosition != null && mounted) {
setState(() {
currentLocation = LatLng(
safePosition.latitude,
safePosition.longitude,
);
});
}
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error getting location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
} catch (_) {}
}
}
int? _getNextPickupStep() {
// Find the first non-skipped pickup (next pickup from user location)
for (int i = 0; i < widget.pickup.length; i++) {
final pickup = widget.pickup[i];
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
if (status != 'skipped') {
final stepNumber = _getPreservedOrCurrentStep(pickup);
if (stepNumber > 0) {
return stepNumber;
} else {
// Calculate display step for bookings without step
final ordersWithStepBefore = widget.pickup
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.pickup
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
return totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
}
}
return null;
}
Future<void> _createPickupMarkers() async {
final tempMarkers = <Marker>[];
List<_StepPoint> stepPoints = [];
_pickupMap.clear();
final nextStep = _getNextPickupStep();
for (int i = 0; i < widget.pickup.length; i++) {
final pickup = widget.pickup[i];
final stepNumber = _getPreservedOrCurrentStep(pickup);
int displayStep;
if (stepNumber > 0) {
displayStep = stepNumber;
} else {
final ordersWithStepBefore = widget.pickup
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.pickup
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
final double lat = _parseD(pickup['droplat'] ?? pickup['pickuplat']);
final double lon = _parseD(pickup['droplon'] ?? pickup['pickuplong']);
if (lat == 0 || lon == 0) continue;
final orderId = (pickup['orderid'] ?? '').toString();
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
final isNext = displayStep == nextStep;
// Save pickup data for dialog
_pickupMap['pickup_$orderId'] = pickup;
// ── A widget, not a bitmap ──
//
// This used to await `_createCircularMarkerBitmap`, a hundred lines that
// opened a `PictureRecorder`, painted a circle and a number onto a canvas,
// rasterised it to PNG bytes and handed the bytes to the platform — per
// marker, per rebuild, asynchronously, because a Google Maps marker is a
// texture and cannot contain a `Text`. A `flutter_map` marker is a widget,
// so the number is just a number, in the app's own font.
//
// The tap that opened the customer sheet moves onto the widget too. The
// `InfoWindow` that carried the name is gone with it: it only appeared on
// tap, covered the route it was anchored to, and said what the sheet the
// same tap opens says properly.
tempMarkers.add(
milerMarker(
point: LatLng(lat, lon),
width: MilerNumberPin.size + 8,
height: MilerNumberPin.size + MilerNumberPin.tail,
child: GestureDetector(
onTap: () => _showCustomerDetailsSheet(pickup, displayStep),
child: MilerNumberPin(
number: displayStep,
color: isSkipped
? ColorConstants.warning
: isNext
? ColorConstants.primary
: ColorConstants.secondaryText,
),
),
),
);
stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon)));
}
if (currentLocation != null) {
tempMarkers.add(
milerMarker(
point: currentLocation!,
width: MilerRiderDot.size,
height: MilerRiderDot.size,
child: const MilerRiderDot(),
),
);
stepPoints.insert(0, _StepPoint(0, currentLocation!));
}
stepPoints.sort((a, b) => a.step.compareTo(b.step));
final newPolylines = <Polyline>[];
_fullRoute.clear();
for (int i = 0; i < stepPoints.length - 1; i++) {
final start = stepPoints[i].position;
final end = stepPoints[i + 1].position;
// OSRM instead of Directions — same answer, no key. See [MilerRouter].
final routePoints = await MilerRouter.route(start, end);
if (routePoints.isNotEmpty) {
_fullRoute.addAll(routePoints);
newPolylines.add(
Polyline(
points: routePoints,
strokeWidth: 6,
// The brand accent, matching the single-stop map. This was
// `Colors.blue`, so the two map screens drew the same route in two
// different colours.
color: ColorConstants.primary,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
),
);
} else {
// A leg the router could not resolve still has to enter the animated
// path, or the trace stops dead at the first unroutable stop.
_fullRoute.addAll([start, end]);
newPolylines.add(
Polyline(
points: [start, end],
strokeWidth: 4,
// Straight-line fallback — lighter, because it is a guess at the
// route rather than the route.
color: ColorConstants.primary.withValues(alpha: 0.35),
pattern: StrokePattern.dashed(segments: const [20, 12]),
),
);
}
}
if (mounted) {
setState(() {
markers = tempMarkers;
// The resolved segments become the faint casing — the route as known —
// and the bright line is then traced over them. Same two-layer treatment
// the single-stop map uses, so the two screens animate identically.
casingLines = [
for (final p in newPolylines)
Polyline(
points: p.points,
color: ColorConstants.primary.withValues(alpha: 0.18),
strokeWidth: 9,
pattern: p.pattern,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
),
];
});
if (_fullRoute.length > 1) {
_buildDistanceTable();
_routeAnim.forward(from: 0);
}
}
await Future.delayed(const Duration(milliseconds: 200));
_fitBoundsToMarkersAndPolylines();
}
/// Distance from the route's start to each point in [_fullRoute].
///
/// The animation walks this table rather than the point list, and that is the
/// difference between the head moving and the head stuttering. A routing API
/// puts vertices where the road bends, so a roundabout can carry twenty of
/// them across thirty metres while a bypass carries two across a kilometre.
/// Stepping by index spends equal time on each, which makes the head crawl
/// through every junction and leap down every straight.
List<double> _cumulative = <double>[];
void _buildDistanceTable() {
_cumulative = List<double>.filled(_fullRoute.length, 0);
if (_fullRoute.length < 2) return;
// Longitude degrees shrink with latitude, so scale them before measuring or
// an east–west leg is over-weighted. Plane geometry is ample here: the table
// only has to get the *ratios* between legs right.
final meanLat =
_fullRoute.map((p) => p.latitude).reduce((a, b) => a + b) /
_fullRoute.length;
final lonScale = math.cos(meanLat * math.pi / 180).abs().clamp(0.05, 1.0);
for (var i = 1; i < _fullRoute.length; i++) {
final a = _fullRoute[i - 1], b = _fullRoute[i];
final dy = b.latitude - a.latitude;
final dx = (b.longitude - a.longitude) * lonScale;
_cumulative[i] = _cumulative[i - 1] + math.sqrt(dy * dy + dx * dx);
}
}
/// The point [f] of the way along the route *by length*, as a segment index
/// plus a fraction into that segment.
(int, double) _atDistance(double f) {
final total = _cumulative.isEmpty ? 0.0 : _cumulative.last;
if (total <= 0) return (0, 0);
final target = (f.clamp(0.0, 1.0)) * total;
var lo = 0, hi = _cumulative.length - 1;
while (lo < hi) {
final mid = (lo + hi) >> 1;
if (_cumulative[mid] <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
final i = (lo - 1).clamp(0, _fullRoute.length - 2);
final span = _cumulative[i + 1] - _cumulative[i];
return (
i,
span <= 0 ? 0.0 : ((target - _cumulative[i]) / span).clamp(0.0, 1.0),
);
}
/// Reveals the bright line progressively, with an interpolated leading tip.
void _onRouteTick() {
if (!mounted || _fullRoute.length < 2) return;
if (_cumulative.length != _fullRoute.length) _buildDistanceTable();
final t = Curves.easeInOutCubic.transform(_routeAnim.value);
final (idx, frac) = _atDistance(t);
final shown = <LatLng>[..._fullRoute.sublist(0, idx + 1)];
if (idx < _fullRoute.length - 1) {
final a = _fullRoute[idx];
final b = _fullRoute[idx + 1];
shown.add(
LatLng(
a.latitude + (b.latitude - a.latitude) * frac,
a.longitude + (b.longitude - a.longitude) * frac,
),
);
}
progressLine = Polyline(
points: shown,
color: ColorConstants.primary,
strokeWidth: 6,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
// Only the map rebuilds — a `setState` here redrew the whole screen, sheet
// and all, sixty times a second to move one line.
_mapRepaint.value++;
}
/// Frames every stop and every metre of route.
///
/// The retry-with-delay dance this replaces existed because
/// `GoogleMapController.animateCamera` threw until the platform view had
/// finished creating itself. `flutter_map`'s camera is available as soon as
/// the map is in the tree.
Future<void> _fitBoundsToMarkersAndPolylines() async {
if (!mounted) return;
final points = <LatLng>[
for (final m in markers) m.point,
for (final poly in routeLayers) ...poly.points,
];
if (points.isEmpty) return;
await _camera.fit(points, padding: const EdgeInsets.all(64));
}
void _showCustomerDetailsSheet(Map<String, dynamic> pickup, int stepNumber) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext context) {
final customerName = (pickup['pickupcustomer'] ?? 'Customer')
.toString();
final address = (pickup['pickupaddress'] ?? 'Address not available')
.toString();
final phone = (pickup['pickupcontactno'] ?? '').toString();
final orderId = (pickup['orderid'] ?? '').toString();
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and close icon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Customer Details',
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primary,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ColorConstants.dividerColor,
shape: BoxShape.circle,
),
// A close affordance, not a destructive action.
// This was a 34px red `Icons.cancel` — the loudest
// thing in the sheet, competing with the call button
// and reading as "delete this stop".
child: Icon(
Icons.close_rounded,
size: 20.sp,
color: ColorConstants.slateText,
),
),
),
],
),
const Divider(thickness: 1.5),
// Step number and status
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isSkipped
? ColorConstants.warning
: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'Step $stepNumber',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14.sp,
),
),
),
if (isSkipped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: ColorConstants.warning,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'SKIPPED',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12.sp,
),
),
),
],
],
),
const SizedBox(height: 16),
// Customer name
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.person,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Customer Name',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
customerName,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Pickup address
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipient Address',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
address,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Phone number
if (phone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.phone,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact Number',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
phone,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Booking ID
Text(
'Booking ID: $orderId',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 20),
// Call button
MilerButton(
label: 'Call',
icon: Icons.phone,
color: ColorConstants.acceptGreen,
onPressed: () async {
// Close bottom sheet first
Navigator.of(context).pop();
// Small delay to ensure bottom sheet is closed
await Future.delayed(const Duration(milliseconds: 300));
// Then make the call
final bool success = await launchPhoneDialer(phone);
if (!success && context.mounted) {
AppFeedback.error(context, 'Could not open the dialer');
}
},
),
const SizedBox(height: 8),
],
),
),
),
),
);
},
);
}
@override
void dispose() {
// The class had no dispose before the route animation was added — a ticker
// outliving its State keeps the whole subtree alive and fires into a widget
// that is no longer mounted.
_routeAnim.dispose();
_mapRepaint.dispose();
_camera.dispose();
mapController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
LatLng initialTarget;
double initialZoom = 13;
if (currentLocation != null) {
initialTarget = currentLocation!;
} else if (widget.pickup.isNotEmpty) {
final firstPickup = widget.pickup.first;
final lat = _parseD(firstPickup['droplat'] ?? firstPickup['pickuplat']);
final lon = _parseD(firstPickup['droplon'] ?? firstPickup['pickuplong']);
initialTarget = (lat != 0 && lon != 0)
? LatLng(lat, lon)
: const LatLng(11.0168, 76.9558);
} else {
initialTarget = const LatLng(11.0168, 76.9558);
}
return Scaffold(
// ── A light app bar, not a red slab ──
//
// This was a solid brand-red bar with white text, 80pt tall, built out of
// a PreferredSize wrapping an AppBar whose `leading` was an infinitely
// wide Row holding both the back arrow and the title. Two problems: a
// saturated red band across the top of a map competes with the route drawn
// on it — the map is the content, and the chrome was louder than it — and
// every other pushed screen in the app has a white bar, so this one read
// as a different app.
//
// Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on
// the title where it costs nothing, a real `leading` slot, and the same
// hairline the rest of the app uses to close a bar.
appBar: AppBar(
// The app-wide frosted brand wash — see [milerGlassSurface].
backgroundColor: Colors.transparent,
flexibleSpace: milerGlassSurface(),
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
titleSpacing: 0,
leading: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.arrow_back_rounded,
color: ColorConstants.slateText,
size: 22.sp,
),
onPressed: () => Navigator.pop(context),
),
title: Text(
'Route map',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 19.sp,
color: ColorConstants.primary,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
centerTitle: false,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: AfterEntrance(
placeholder: const MapPlaceholder(),
builder: (context) => ValueListenableBuilder<int>(
valueListenable: _mapRepaint,
builder: (context, _, _) => MilerMap(
controller: mapController,
initialCenter: initialTarget,
initialZoom: initialZoom,
polylines: routeLayers,
markers: markers,
onReady: () {
_mapReady = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_mapReady) _fitBoundsToMarkersAndPolylines();
});
},
),
),
),
);
}
}