Files
Xpress-rider/lib/services/maps/nearle_map.dart
2026-08-12 10:50:45 +05:30

375 lines
11 KiB
Dart

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<LatLng> 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<void> 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<void> animateCamera(CameraUpdate update) =>
_guard(() => _raw.animateCamera(update));
Future<void> moveCamera(CameraUpdate update) =>
_guard(() => _raw.moveCamera(update));
Future<void> _guard(Future<void> 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<NearleMapMarker> markers;
final List<NearleMapPolyline> 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<NearleMap> createState() => _NearleMapState();
}
class _NearleMapState extends State<NearleMap> {
MapLibreMapController? _controller;
NearleMapController? _facade;
bool _styleLoaded = false;
bool _disposed = false;
final Set<String> _registeredIcons = {};
final Map<String, Symbol> _symbols = {};
final Map<String, NearleMapMarker> _markerById = {};
final Map<String, Line> _lines = {};
final Map<String, NearleMapPolyline> _polylineById = {};
bool _syncing = false;
bool _syncQueued = false;
@override
void dispose() {
_disposed = true;
_controller?.onSymbolTapped.remove(_onSymbolTapped);
super.dispose();
}
bool _isAlive() => !_disposed && mounted && _styleLoaded;
Future<void> _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<void> _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<void> _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<void> _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,
);
}
}