import 'dart:async'; import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'map_config.dart'; import 'map_marker_icons.dart'; export 'package:maplibre_gl/maplibre_gl.dart' show LatLng, LatLngBounds, CameraPosition, CameraUpdate, MyLocationTrackingMode; /// Where a marker bitmap sits relative to its coordinate. enum MarkerAnchor { center, bottom } @immutable class NearleMapMarker { const NearleMapMarker({ required this.id, required this.position, required this.icon, this.anchor = MarkerAnchor.bottom, this.size = 1.0, this.onTap, }); final String id; final LatLng position; final MarkerIcon icon; final MarkerAnchor anchor; final double size; final VoidCallback? onTap; /// Identity for diffing. Deliberately excludes [onTap] — a new closure on /// every rebuild must not force the marker to be torn down and re-added. @override bool operator ==(Object other) => other is NearleMapMarker && other.id == id && other.position.latitude == position.latitude && other.position.longitude == position.longitude && other.icon == icon && other.anchor == anchor && other.size == size; @override int get hashCode => Object.hash(id, position.latitude, position.longitude, icon, anchor, size); } @immutable class NearleMapPolyline { const NearleMapPolyline({ required this.id, required this.points, required this.color, this.width = 5.0, }); final String id; final List points; final Color color; final double width; @override bool operator ==(Object other) => other is NearleMapPolyline && other.id == id && other.color == color && other.width == width && other.points.length == points.length && (points.isEmpty || (other.points.first.latitude == points.first.latitude && other.points.first.longitude == points.first.longitude && other.points.last.latitude == points.last.latitude && other.points.last.longitude == points.last.longitude)); @override int get hashCode => Object.hash(id, color, width, points.length); } /// Thin, crash-tolerant facade over [MapLibreMapController]. class NearleMapController { NearleMapController._(this._raw, this._isAlive); final MapLibreMapController _raw; final bool Function() _isAlive; /// Escape hatch for anything this facade does not wrap. MapLibreMapController get raw => _raw; /// Frames [bounds] with uniform [padding] in logical pixels. /// /// Degenerate bounds (a single point, or a perfectly horizontal/vertical /// pair) make the native fit routine fail, so they are nudged outward first. Future fitBounds(LatLngBounds bounds, {double padding = 80}) async { if (!_isAlive()) return; const double minSpan = 0.0015; // ~165 m var south = bounds.southwest.latitude; var north = bounds.northeast.latitude; var west = bounds.southwest.longitude; var east = bounds.northeast.longitude; if (north - south < minSpan) { final mid = (north + south) / 2; south = mid - minSpan / 2; north = mid + minSpan / 2; } if (east - west < minSpan) { final mid = (east + west) / 2; west = mid - minSpan / 2; east = mid + minSpan / 2; } await _guard( () => _raw.animateCamera( CameraUpdate.newLatLngBounds( LatLngBounds( southwest: LatLng(south, west), northeast: LatLng(north, east), ), left: padding, top: padding, right: padding, bottom: padding, ), ), ); } Future animateCamera(CameraUpdate update) => _guard(() => _raw.animateCamera(update)); Future moveCamera(CameraUpdate update) => _guard(() => _raw.moveCamera(update)); Future _guard(Future Function() action) async { if (!_isAlive()) return; try { await action(); } catch (e) { debugPrint('[NEARLE_MAP] Camera command skipped: $e'); } } } /// The app's single map surface, backed by MapLibre + OpenStreetMap vector /// tiles. No API key is involved anywhere in this widget. /// /// Markers and polylines are declarative: pass new lists and the widget diffs /// them against what is on the map, updating only what actually changed. That /// matters on the navigation screen, where the rider marker moves every few /// metres and a clear-and-re-add would flicker. class NearleMap extends StatefulWidget { const NearleMap({ super.key, required this.initialCameraPosition, this.markers = const [], this.polylines = const [], this.myLocationEnabled = false, this.trackingMode = MyLocationTrackingMode.none, this.compassEnabled = false, this.zoomGesturesEnabled = true, this.scrollGesturesEnabled = true, this.rotateGesturesEnabled = true, this.tiltGesturesEnabled = true, this.onMapReady, }); final CameraPosition initialCameraPosition; final List markers; final List polylines; final bool myLocationEnabled; final MyLocationTrackingMode trackingMode; final bool compassEnabled; final bool zoomGesturesEnabled; final bool scrollGesturesEnabled; final bool rotateGesturesEnabled; final bool tiltGesturesEnabled; final void Function(NearleMapController controller)? onMapReady; @override State createState() => _NearleMapState(); } class _NearleMapState extends State { MapLibreMapController? _controller; NearleMapController? _facade; bool _styleLoaded = false; bool _disposed = false; final Set _registeredIcons = {}; final Map _symbols = {}; final Map _markerById = {}; final Map _lines = {}; final Map _polylineById = {}; bool _syncing = false; bool _syncQueued = false; @override void dispose() { _disposed = true; _controller?.onSymbolTapped.remove(_onSymbolTapped); super.dispose(); } bool _isAlive() => !_disposed && mounted && _styleLoaded; Future _onStyleLoaded() async { final controller = _controller; if (controller == null || _disposed) return; _styleLoaded = true; // Delivery pins overlap constantly in dense areas; without this MapLibre // silently hides the collided ones. try { await controller.setSymbolIconAllowOverlap(true); await controller.setSymbolIconIgnorePlacement(true); } catch (e) { debugPrint('[NEARLE_MAP] Overlap config skipped: $e'); } await _sync(); final facade = _facade; if (facade != null && !_disposed) widget.onMapReady?.call(facade); } @override void didUpdateWidget(covariant NearleMap oldWidget) { super.didUpdateWidget(oldWidget); final markersChanged = !listEquals(oldWidget.markers, widget.markers); final linesChanged = !listEquals(oldWidget.polylines, widget.polylines); if (markersChanged || linesChanged) _sync(); } /// Reconciles the widget's markers/polylines with what is on the map. Future _sync() async { final controller = _controller; if (controller == null || !_styleLoaded || _disposed) return; // Native annotation calls are not re-entrant; coalesce overlapping syncs. if (_syncing) { _syncQueued = true; return; } _syncing = true; try { await _syncPolylines(controller); await _syncMarkers(controller); } catch (e) { debugPrint('[NEARLE_MAP] Annotation sync failed: $e'); } finally { _syncing = false; if (_syncQueued && !_disposed) { _syncQueued = false; unawaited(_sync()); } } } Future _syncPolylines(MapLibreMapController controller) async { final desired = {for (final p in widget.polylines) p.id: p}; for (final id in _lines.keys.toList()) { if (desired.containsKey(id)) continue; final line = _lines.remove(id); _polylineById.remove(id); if (line != null) await controller.removeLine(line); } for (final polyline in widget.polylines) { if (polyline.points.length < 2) continue; final existing = _lines[polyline.id]; final options = LineOptions( geometry: polyline.points, lineColor: _hex(polyline.color), lineWidth: polyline.width, lineOpacity: 1.0, lineJoin: 'round', ); if (existing == null) { _lines[polyline.id] = await controller.addLine(options); } else if (_polylineById[polyline.id] != polyline) { await controller.updateLine(existing, options); } _polylineById[polyline.id] = polyline; } } Future _syncMarkers(MapLibreMapController controller) async { for (final marker in widget.markers) { if (_registeredIcons.add(marker.icon.key)) { try { await controller.addImage(marker.icon.key, marker.icon.bytes); } catch (e) { _registeredIcons.remove(marker.icon.key); debugPrint('[NEARLE_MAP] Icon registration failed: $e'); } } } final desired = {for (final m in widget.markers) m.id: m}; for (final id in _symbols.keys.toList()) { if (desired.containsKey(id)) continue; final symbol = _symbols.remove(id); _markerById.remove(id); if (symbol != null) await controller.removeSymbol(symbol); } for (final marker in widget.markers) { final options = SymbolOptions( geometry: marker.position, iconImage: marker.icon.key, iconSize: marker.size, iconAnchor: marker.anchor == MarkerAnchor.bottom ? 'bottom' : 'center', ); final existing = _symbols[marker.id]; if (existing == null) { _symbols[marker.id] = await controller.addSymbol(options, { 'nearleId': marker.id, }); } else if (_markerById[marker.id] != marker) { await controller.updateSymbol(existing, options); } _markerById[marker.id] = marker; } } void _onSymbolTapped(Symbol symbol) { final id = symbol.data?['nearleId']; if (id is! String) return; // Read the tap handler from the current widget, not the cached marker, so // it always closes over fresh state. for (final marker in widget.markers) { if (marker.id == id) { marker.onTap?.call(); return; } } } static String _hex(Color color) => '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}'; @override Widget build(BuildContext context) { return MapLibreMap( styleString: MapConfig.styleUrl, initialCameraPosition: widget.initialCameraPosition, myLocationEnabled: widget.myLocationEnabled, myLocationTrackingMode: widget.trackingMode, compassEnabled: widget.compassEnabled, zoomGesturesEnabled: widget.zoomGesturesEnabled, scrollGesturesEnabled: widget.scrollGesturesEnabled, rotateGesturesEnabled: widget.rotateGesturesEnabled, tiltGesturesEnabled: widget.tiltGesturesEnabled, trackCameraPosition: true, onMapCreated: (controller) { _controller = controller; _facade = NearleMapController._(controller, _isAlive); controller.onSymbolTapped.add(_onSymbolTapped); }, onStyleLoadedCallback: _onStyleLoaded, ); } }