new changes maps
This commit is contained in:
374
lib/services/maps/nearle_map.dart
Normal file
374
lib/services/maps/nearle_map.dart
Normal file
@@ -0,0 +1,374 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user