375 lines
11 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|