import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../../data/location_service.dart'; import '../../data/map_config.dart'; import '../../data/models.dart'; import '../tokens.dart'; import 'map_tiles.dart'; /// A real map of the journey: pickup and destination joined by the transit /// path, with the Miler's live position when there is one. /// /// Read-only by design โ€” it summarises a booking, and a non-interactive map /// never steals a scroll gesture from the list it sits in. class DmRouteMap extends StatefulWidget { const DmRouteMap({ super.key, this.height = 190, this.pickup, this.destination, this.progress = 0, this.showMiler = false, this.pickupLabel, this.destinationLabel, this.badge, this.overlay, this.radius = DmRadius.lg, }); final double height; /// Where the Miler collects. Without coordinates the map falls back to the /// service-area centre rather than showing an empty ocean. final Place? pickup; /// The destination hub. Null draws pickup only โ€” which is exactly right /// before a district has been chosen. final Place? destination; /// How far along the route the Miler is, 0..1. final double progress; final bool showMiler; final String? pickupLabel; final String? destinationLabel; /// Pill shown top-left, e.g. "En route ยท 1.4 km away". final Widget? badge; /// Anything centred over the map, e.g. the "searching" state. final Widget? overlay; final double radius; @override State createState() => _DmRouteMapState(); } class _DmRouteMapState extends State with TickerProviderStateMixin { late final AnimationController _pulse = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), )..repeat(); late final AnimationController _draw = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..forward(); @override void dispose() { _pulse.dispose(); _draw.dispose(); super.dispose(); } LatLng? get _from => widget.pickup != null && widget.pickup!.hasLocation ? LatLng(widget.pickup!.lat!, widget.pickup!.lng!) : null; LatLng? get _to => widget.destination != null && widget.destination!.hasLocation ? LatLng(widget.destination!.lat!, widget.destination!.lng!) : null; /// A gentle arc rather than a ruler-straight line โ€” it reads as a journey, /// and it separates the two ends when they are nearly aligned. List _arc(LatLng a, LatLng b) { final dLat = b.latitude - a.latitude; final dLng = b.longitude - a.longitude; // Perpendicular offset, proportional to the span. final bowLat = -dLng * 0.12; final bowLng = dLat * 0.12; final control = LatLng( (a.latitude + b.latitude) / 2 + bowLat, (a.longitude + b.longitude) / 2 + bowLng, ); return [ for (var i = 0; i <= 48; i++) _quadratic(a, control, b, i / 48), ]; } static LatLng _quadratic(LatLng a, LatLng c, LatLng b, double t) { final u = 1 - t; return LatLng( u * u * a.latitude + 2 * u * t * c.latitude + t * t * b.latitude, u * u * a.longitude + 2 * u * t * c.longitude + t * t * b.longitude, ); } @override Widget build(BuildContext context) { final from = _from; final to = _to; final route = (from != null && to != null) ? _arc(from, to) : null; return ClipRRect( borderRadius: DmRadius.all(widget.radius), child: SizedBox( height: widget.height, child: ColoredBox( color: DmMapTiles.ground, child: Stack( children: [ Positioned.fill( child: _map(context, from: from, to: to, route: route), ), if (widget.badge != null) Positioned( top: 12, left: 12, // Held to half the map's width so it cannot run under the // attribution chip opposite it, whatever the badge says. child: ConstrainedBox( constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width * 0.5, ), child: widget.badge!, ), ), if (widget.pickupLabel != null) Positioned( left: 12, bottom: 12, child: _MapLabel( icon: LucideIcons.circleDot, color: DmColors.ink, label: widget.pickupLabel!, ), ), if (widget.destinationLabel != null) Positioned( right: 12, bottom: 12, child: _MapLabel( icon: LucideIcons.mapPin, color: DmColors.brand, label: widget.destinationLabel!, ), ), // Attribution is required on every map, so it sits top-right, // clear of the badge (top-left) and the labels (bottom). const Positioned(top: 10, right: 10, child: DmMapAttribution()), if (widget.overlay != null) Center(child: widget.overlay!), ], ), ), ), ); } Widget _map( BuildContext context, { required LatLng? from, required LatLng? to, required List? route, }) { final centre = from ?? to ?? const LatLng( LocationService.fallbackLat, LocationService.fallbackLng, ); // The map itself is built once. Only the layers that animate sit inside an // AnimatedBuilder, so a pulsing Miler never re-creates the camera. return FlutterMap( options: MapOptions( initialCenter: centre, initialZoom: to == null ? 13.5 : 11, initialCameraFit: route == null ? null : CameraFit.coordinates( coordinates: route, padding: const EdgeInsets.fromLTRB(46, 34, 46, 52), maxZoom: 14, ), backgroundColor: DmMapTiles.ground, interactionOptions: const InteractionOptions( flags: InteractiveFlag.none, ), ), children: [ // The quieter basemap: this map is a summary, the route is the subject. DmMapTiles.layer(context, style: DmMapStyle.muted), if (route != null) AnimatedBuilder( animation: _draw, builder: (context, _) { final drawn = Curves.easeOutCubic.transform(_draw.value); final upto = (route.length * drawn).ceil().clamp(2, route.length); return PolylineLayer( polylines: [ Polyline( points: route.sublist(0, upto), color: DmColors.brand, strokeWidth: 4, borderColor: Colors.white, borderStrokeWidth: 3, ), ], ); }, ), AnimatedBuilder( animation: widget.showMiler ? Listenable.merge([_pulse, _draw]) : _draw, builder: (context, _) { final drawn = Curves.easeOutCubic.transform(_draw.value); return MarkerLayer( markers: [ if (from != null) Marker( point: from, width: 26, height: 26, child: const _OriginDot(), ), if (to != null) Marker( point: to, width: 34, height: 40, alignment: Alignment.topCenter, child: const _DestinationPin(), ), if (widget.showMiler && route != null) Marker( point: route[( (route.length - 1) * widget.progress.clamp(0.02, 0.98) * drawn ).round()], width: 58, height: 58, child: _MilerDot(pulse: _pulse.value), ), ], ); }, ), ], ); } } class _OriginDot extends StatelessWidget { const _OriginDot(); @override Widget build(BuildContext context) { return Center( child: Container( width: 18, height: 18, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, border: Border.all(color: DmColors.ink, width: 3.5), boxShadow: DmShadow.float, ), ), ); } } class _DestinationPin extends StatelessWidget { const _DestinationPin(); @override Widget build(BuildContext context) { return const Stack( alignment: Alignment.topCenter, children: [ Icon( LucideIcons.mapPin, size: 34, color: DmColors.brand, shadows: [ Shadow(color: Color(0x338F0F06), blurRadius: 8, offset: Offset(0, 4)), ], ), Positioned( top: 9, child: CircleAvatar(radius: 4, backgroundColor: Colors.white), ), ], ); } } /// The Miler's live position. The expanding ring is what makes it read as live. class _MilerDot extends StatelessWidget { const _MilerDot({required this.pulse}); final double pulse; @override Widget build(BuildContext context) { return Center( child: Stack( alignment: Alignment.center, children: [ Container( width: 24 + pulse * 30, height: 24 + pulse * 30, decoration: BoxDecoration( shape: BoxShape.circle, color: DmColors.brand.withValues(alpha: (1 - pulse) * 0.22), ), ), Container( width: 26, height: 26, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, boxShadow: DmShadow.float, ), child: const Icon( LucideIcons.bike, size: 15, color: DmColors.brand, ), ), ], ), ); } } class _MapLabel extends StatelessWidget { const _MapLabel({required this.icon, required this.color, required this.label}); final IconData icon; final Color color; final String label; @override Widget build(BuildContext context) { return Container( constraints: const BoxConstraints(maxWidth: 168), padding: const EdgeInsets.fromLTRB(9, 6, 12, 6), decoration: BoxDecoration( color: DmColors.surface, border: Border.all(color: DmColors.border), borderRadius: DmRadius.all(6), boxShadow: DmShadow.float, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 6), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.label.copyWith(fontSize: 12), ), ), ], ), ); } }