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> pickup; final Map preservedStepNumbers; const MultiCustomerMapScreen({ super.key, required this.pickup, this.preservedStepNumbers = const {}, }); @override State createState() => _MultiCustomerMapScreenState(); } class _MultiCustomerMapScreenState extends State with SingleTickerProviderStateMixin { final MapController mapController = MapController(); late final MilerMapCamera _camera; List markers = []; /// The route as known, faint, underneath. List casingLines = []; /// 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 get routeLayers => [ ...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 _mapRepaint = ValueNotifier(0); LatLng? currentLocation; bool _isLoading = true; bool _mapReady = false; final Map> _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 _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 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 Booking) { final pickupId = (Booking['pickupid'] ?? '').toString(); final orderId = (Booking['orderid'] ?? '').toString(); return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId'; } int _getPreservedOrCurrentStep(Map Booking) { final orderKey = _getOrderKey(Booking); if (widget.preservedStepNumbers.containsKey(orderKey)) { return widget.preservedStepNumbers[orderKey]!; } final currentStep = _getStepNumber(Booking); return currentStep; } Future _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 _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 _createPickupMarkers() async { final tempMarkers = []; 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 = []; _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 _cumulative = []; void _buildDistanceTable() { _cumulative = List.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 = [..._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 _fitBoundsToMarkersAndPolylines() async { if (!mounted) return; final points = [ 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 pickup, int stepNumber) { // ── Through the kit ── // // The furthest-drifted sheet in the app, and every difference was a // decision re-made rather than inherited: // // • its own `RoundedRectangleBorder(20)` — a radius nothing else uses; // • **no glass at all**, so it arrived opaque white over a live map while // every other sheet is frosted; // • `SafeArea(bottom: true)` **and** `Padding(bottom: viewInsets.bottom)` // — the two summed, which is the double-inset bug written out in full: // with the keyboard up the content floats a navigation bar's height // above it; // • a 24 gutter against the app's 20; // • a 22sp/w600 title in **brand red**, where the system's header is // 18/w700 slate — the accent belongs on the badge, not on the sentence; // • and a close ✕, which the handle and the barrier already do. showMilerSheet( context, 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 MilerSheetScaffold( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const MilerSheetHeader( title: 'Customer details', icon: LucideIcons.mapPinned, ), SizedBox(height: 4.h), // 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.w700, 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.w700, fontSize: 12.sp, ), ), ), ], ], ), const SizedBox(height: 16), // Customer name Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( LucideIcons.user, 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.w700, ), ), 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( LucideIcons.mapPin, 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.w700, ), ), 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( LucideIcons.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.w700, ), ), 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: LucideIcons.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. // ── The sixth app-bar implementation, folded into the one ── // // This was a hand-rolled `AppBar`: a frosted wash, a maroon 19sp title // and its own back button — so the map page's heading was a different // colour and a different size from every other pushed screen. The audit // note on [milerPageBar] says that bar is the only one a pushed screen // gets; this one had simply never been converted. // // Now it inherits the brand ground, the white title and the shared back // control along with everything else, and cannot drift again. appBar: milerPageBar('Route map', onBack: () => Navigator.pop(context)), body: _isLoading ? const Center(child: CircularProgressIndicator()) : AfterEntrance( placeholder: const MapPlaceholder(), builder: (context) => ValueListenableBuilder( valueListenable: _mapRepaint, builder: (context, _, _) => MilerMap( controller: mapController, initialCenter: initialTarget, initialZoom: initialZoom, polylines: routeLayers, markers: markers, onReady: () { _mapReady = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (_mapReady) _fitBoundsToMarkersAndPolylines(); }); }, ), ), ), ); } }