import 'dart:async'; import 'dart:math' as math; 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 '../tokens.dart'; import 'map_tiles.dart'; /// The pickup map: a real, pannable map with a pin fixed to the centre. /// /// The pin never moves — the map moves under it, the way every ride-hailing /// app does it. When the map settles, [onPinMoved] fires with the coordinates /// under the pin so the caller can reverse-geocode them. class DmMapPanel extends StatefulWidget { const DmMapPanel({ super.key, required this.lat, required this.lng, this.height, this.loading = false, this.interactive = true, this.zoom = 16.5, this.deviceLat, this.deviceLng, this.deviceAccuracy, this.onLocate, this.onPinMoveStart, this.onPinMoved, this.borderRadius = const BorderRadius.all(Radius.circular(DmRadius.lg)), }); /// Where the pin sits. Changing these recentres the map, unless the customer /// is mid-gesture — their drag always wins. final double lat; final double lng; /// Fixed height, or null to fill whatever the parent gives it — which is how /// it sits inside the pickup card. final double? height; /// True while we are still resolving a position; dims the pin. final bool loading; /// False makes the map a picture: no dragging, no pin lift, no callbacks. final bool interactive; final double zoom; /// Where the customer is, drawn as the blue dot. Null hides it — a refused /// permission should show no dot rather than a dot in the wrong place. /// /// Distinct from [lat]/[lng], which is where the *pickup pin* is: after a /// drag the two separate, which is exactly the information the dot carries. final double? deviceLat; final double? deviceLng; /// Horizontal accuracy in metres. Drawn as a halo at true map scale, so it /// shrinks as the customer zooms in. Null draws the dot alone. final double? deviceAccuracy; /// Shows the "my location" control when set. final VoidCallback? onLocate; /// Fires the moment a drag begins, so the address can go stale-quiet. final VoidCallback? onPinMoveStart; /// Fires once the map has come to rest under the pin. final void Function(double lat, double lng)? onPinMoved; final BorderRadius borderRadius; @override State createState() => _DmMapPanelState(); } class _DmMapPanelState extends State with TickerProviderStateMixin { final MapController _map = MapController(); /// Raises the pin off the map while the customer drags. late final AnimationController _lift = AnimationController( vsync: this, duration: const Duration(milliseconds: 180), ); late final AnimationController _glide = AnimationController( vsync: this, duration: const Duration(milliseconds: 420), ); Timer? _settle; bool _ready = false; bool _dragging = false; /// The last position the parent knows about — either what it handed us or /// what we last reported. Used to tell an external recentre from an echo of /// our own callback. late LatLng _known = LatLng(widget.lat, widget.lng); @override void dispose() { _settle?.cancel(); _lift.dispose(); _glide.dispose(); _map.dispose(); super.dispose(); } @override void didUpdateWidget(covariant DmMapPanel old) { super.didUpdateWidget(old); if (widget.lat == old.lat && widget.lng == old.lng) return; final target = LatLng(widget.lat, widget.lng); // Our own reverse-geocode coming back, or a genuinely new place? if (_metres(target, _known) < 20) { _known = target; return; } _known = target; if (!_dragging) _glideTo(target); } /// flutter_map moves instantly; a short eased glide keeps "locate me" and a /// searched address from teleporting. void _glideTo(LatLng target) { if (!_ready) return; final from = _map.camera.center; final fromZoom = _map.camera.zoom; _glide ..stop() ..reset(); void tick() { final t = Curves.easeInOutCubic.transform(_glide.value); _map.move( LatLng( from.latitude + (target.latitude - from.latitude) * t, from.longitude + (target.longitude - from.longitude) * t, ), fromZoom + (widget.zoom - fromZoom) * t, ); } _glide.addListener(tick); _glide.forward().whenComplete(() => _glide.removeListener(tick)); } void _onEvent(MapEvent event) { if (!widget.interactive) return; if (event is MapEventMoveStart || event is MapEventFlingAnimationStart || event is MapEventDoubleTapZoomStart) { _settle?.cancel(); if (!_dragging) { _dragging = true; _lift.forward(); widget.onPinMoveStart?.call(); } return; } if (event is MapEventMoveEnd || event is MapEventFlingAnimationEnd || event is MapEventDoubleTapZoomEnd) { _dragging = false; _lift.reverse(); final centre = event.camera.center; // A flick that ends and restarts should resolve once, not twice. _settle?.cancel(); _settle = Timer(const Duration(milliseconds: 320), () { if (!mounted) return; _known = centre; widget.onPinMoved?.call(centre.latitude, centre.longitude); }); } } static double _metres(LatLng a, LatLng b) { const earth = 6371000.0; double rad(double d) => d * math.pi / 180; final dLat = rad(b.latitude - a.latitude); final dLng = rad(b.longitude - a.longitude); final h = math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(rad(a.latitude)) * math.cos(rad(b.latitude)) * math.sin(dLng / 2) * math.sin(dLng / 2); return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h)); } /// The customer's own position: an accuracy halo at map scale with the dot /// on top. Returns null when there is no fix to draw. Widget? get _deviceLayers { final lat = widget.deviceLat; final lng = widget.deviceLng; if (lat == null || lng == null) return null; final at = LatLng(lat, lng); final accuracy = widget.deviceAccuracy; return Stack( children: [ if (accuracy != null && accuracy > 0) CircleLayer( circles: [ CircleMarker( point: at, // True metres, so the halo tells the truth at every zoom. radius: accuracy, useRadiusInMeter: true, color: DmColors.locationHalo, borderColor: DmColors.locationDot.withValues(alpha: 0.28), borderStrokeWidth: 1, ), ], ), MarkerLayer( markers: [ Marker( key: const ValueKey('deviceLocationDot'), point: at, width: 22, height: 22, child: const _DeviceDot(), ), ], ), ], ); } @override Widget build(BuildContext context) { final map = ClipRRect( borderRadius: widget.borderRadius, child: ColoredBox( color: DmMapTiles.ground, child: Stack( children: [ Positioned.fill( child: FlutterMap( mapController: _map, options: MapOptions( initialCenter: LatLng(widget.lat, widget.lng), initialZoom: widget.zoom, minZoom: 4, maxZoom: 19, backgroundColor: DmMapTiles.ground, onMapReady: () => _ready = true, onMapEvent: _onEvent, interactionOptions: InteractionOptions( // North stays up: rotation buys nothing here and makes // the fixed pin read as broken. flags: widget.interactive ? InteractiveFlag.drag | InteractiveFlag.flingAnimation | InteractiveFlag.pinchMove | InteractiveFlag.pinchZoom | InteractiveFlag.doubleTapZoom : InteractiveFlag.none, ), ), children: [ DmMapTiles.layer(context), ?_deviceLayers, ], ), ), // The pin is an overlay, not a marker: it belongs to the screen, // not to a coordinate. Center( child: IgnorePointer( child: AnimatedOpacity( opacity: widget.loading ? 0.45 : 1, duration: DmMotion.base, child: AnimatedBuilder( animation: _lift, builder: (context, child) => _CentrePin(lift: _lift.value), ), ), ), ), if (widget.onLocate != null) Positioned( right: 12, bottom: 12, child: _LocateButton( busy: widget.loading, onTap: widget.onLocate!, ), ), const Positioned(left: 10, bottom: 10, child: DmMapAttribution()), ], ), ), ); return widget.height == null ? map : SizedBox(height: widget.height, child: map); } } /// The fixed centre pin. [lift] 0..1 raises it and shrinks its shadow, so a /// drag feels like picking the pin up rather than dragging the ground. class _CentrePin extends StatelessWidget { const _CentrePin({required this.lift}); final double lift; @override Widget build(BuildContext context) { final eased = Curves.easeOutCubic.transform(lift); return SizedBox( width: 60, height: 74, child: Stack( alignment: Alignment.bottomCenter, children: [ // Ground shadow — the anchor point the pin actually refers to. Positioned( bottom: 8, child: Transform.scale( scale: 1 - eased * 0.35, child: Container( width: 18, height: 6, decoration: BoxDecoration( color: DmColors.ink.withValues(alpha: 0.22 - eased * 0.1), borderRadius: BorderRadius.circular(3), ), ), ), ), Positioned( bottom: 10 + eased * 12, child: const _PinHead(), ), ], ), ); } } /// "You are here". White ring so it stays legible over any cartography, and a /// soft shadow so it sits on the map rather than in it. class _DeviceDot extends StatelessWidget { const _DeviceDot(); @override Widget build(BuildContext context) { return Center( child: Container( width: 18, height: 18, decoration: BoxDecoration( color: DmColors.locationDot, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 3), boxShadow: const [ BoxShadow( color: Color(0x3D14100F), blurRadius: 6, offset: Offset(0, 2), ), ], ), ), ); } } class _PinHead extends StatelessWidget { const _PinHead(); @override Widget build(BuildContext context) { return const Stack( alignment: Alignment.topCenter, children: [ Icon( LucideIcons.mapPin, size: 46, color: DmColors.brand, shadows: [ Shadow( color: Color(0x408F0F06), blurRadius: 12, offset: Offset(0, 5), ), ], ), Positioned( top: 12, child: CircleAvatar(radius: 5.5, backgroundColor: Colors.white), ), ], ); } } class _LocateButton extends StatelessWidget { const _LocateButton({required this.onTap, this.busy = false}); final VoidCallback onTap; final bool busy; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( borderRadius: DmRadius.all(DmRadius.md), boxShadow: DmShadow.float, ), child: Material( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.md), child: InkWell( onTap: busy ? null : onTap, borderRadius: DmRadius.all(DmRadius.md), child: Container( width: DmSpace.tap, height: DmSpace.tap, alignment: Alignment.center, decoration: BoxDecoration( borderRadius: DmRadius.all(DmRadius.md), border: Border.all(color: DmColors.line), ), child: busy ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: DmColors.brand, ), ) : const Icon( LucideIcons.locateFixed, size: 22, color: DmColors.ink, ), ), ), ), ); } }