feat: add Binance Pay webhook handler and implement payment order creation in billing service
This commit is contained in:
@@ -6,12 +6,22 @@ library intaleq_maps;
|
||||
|
||||
// Re-export core MapLibre primitives under familiar names
|
||||
export 'package:maplibre_gl/maplibre_gl.dart'
|
||||
show LatLng, LatLngBounds, CameraUpdate, CameraPosition;
|
||||
show
|
||||
LatLng,
|
||||
LatLngBounds,
|
||||
OfflineRegion,
|
||||
OfflineRegionDefinition,
|
||||
downloadOfflineRegion,
|
||||
getListOfRegions,
|
||||
deleteOfflineRegion,
|
||||
MyLocationRenderMode,
|
||||
MyLocationTrackingMode;
|
||||
|
||||
// Public SDK surface
|
||||
export 'src/intaleq_map_widget.dart';
|
||||
export 'src/intaleq_map_controller.dart';
|
||||
export 'src/styles.dart';
|
||||
export 'src/offline_service.dart';
|
||||
export 'src/constants/colors.dart';
|
||||
export 'src/utils/polyline_utils.dart';
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as mgl;
|
||||
import 'models/geometry.dart';
|
||||
import 'models/bitmap.dart';
|
||||
import 'models/types.dart';
|
||||
|
||||
/// Controls a live [IntaleqMap] widget.
|
||||
///
|
||||
@@ -13,12 +15,12 @@ import 'models/bitmap.dart';
|
||||
/// This API mirrors [GoogleMapController] from `google_maps_flutter`.
|
||||
class IntaleqMapController {
|
||||
IntaleqMapController._({
|
||||
required mgl.MaplibreMapController raw,
|
||||
required mgl.MapLibreMapController raw,
|
||||
required String apiKey,
|
||||
}) : _raw = raw,
|
||||
_apiKey = apiKey;
|
||||
|
||||
final mgl.MaplibreMapController _raw;
|
||||
final mgl.MapLibreMapController _raw;
|
||||
final String _apiKey;
|
||||
|
||||
// ── Internal object registries ─────────────────────────────
|
||||
@@ -39,10 +41,18 @@ class IntaleqMapController {
|
||||
/// Maps our [PolygonId] → live MapLibre [mgl.Fill].
|
||||
final Map<PolygonId, mgl.Fill> _fills = {};
|
||||
|
||||
/// A single, imperatively-managed "user location" symbol (the moving puck).
|
||||
/// Kept OUT of the declarative [Marker] sets so it can be updated on every
|
||||
/// GPS / animation tick without rebuilding the widget tree — see
|
||||
/// [setUserMarker]. Reset to null on style reload (native symbol is
|
||||
/// destroyed) so the next update re-adds it.
|
||||
mgl.Symbol? _userSymbol;
|
||||
bool _userSymbolBusy = false;
|
||||
|
||||
// ── Factory / init ─────────────────────────────────────────
|
||||
|
||||
static Future<IntaleqMapController> create({
|
||||
required mgl.MaplibreMapController raw,
|
||||
required mgl.MapLibreMapController raw,
|
||||
required String apiKey,
|
||||
}) async {
|
||||
final ctrl = IntaleqMapController._(raw: raw, apiKey: apiKey);
|
||||
@@ -61,17 +71,60 @@ class IntaleqMapController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the widget when the map style has finished loading/reloading.
|
||||
/// We clear internal registries because native objects (Symbols, Lines)
|
||||
/// are destroyed on style reload.
|
||||
Future<void> onStyleLoaded() async {
|
||||
_symbols.clear();
|
||||
_symbolToMarker.clear();
|
||||
_lines.clear();
|
||||
_lineToPolyline.clear();
|
||||
_circles.clear();
|
||||
_fills.clear();
|
||||
_loadedImages.clear();
|
||||
// The user-location symbol is destroyed with the old style; forget the
|
||||
// stale handle so the next [setUserMarker] call re-creates it.
|
||||
_userSymbol = null;
|
||||
await _registerDefaultImages();
|
||||
}
|
||||
|
||||
/// Google Maps draws every marker unconditionally, but MapLibre's symbol
|
||||
/// layer defaults to `icon-allow-overlap = false`, so custom markers get
|
||||
/// collision-culled against the basemap's place labels and silently vanish.
|
||||
///
|
||||
/// To match `GoogleMap` semantics (this SDK is a drop-in replacement) we opt
|
||||
/// marker *icons* out of collision. The symbol manager is created before
|
||||
/// `onStyleLoadedCallback` fires, so applying this here — before any markers
|
||||
/// are (re)added on style load — makes pins reliably visible and keeps them
|
||||
/// visible across dark/light style reloads.
|
||||
///
|
||||
/// Only the icon is forced to always draw; the optional info-window text is
|
||||
/// left collision-managed so a marker whose artwork already contains a label
|
||||
/// (e.g. the A/B route pins) doesn't render a duplicate text glyph on top.
|
||||
Future<void> applyMarkerVisibilityDefaults() async {
|
||||
try {
|
||||
await _raw.setSymbolIconAllowOverlap(true);
|
||||
await _raw.setSymbolIconIgnorePlacement(true);
|
||||
} catch (_) {
|
||||
// Symbol manager not ready yet — safe to ignore; defaults reapply on the
|
||||
// next style load.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Camera (same API as GoogleMapController) ──────────────
|
||||
|
||||
/// Animates the camera to the given [update].
|
||||
Future<bool?> animateCamera(mgl.CameraUpdate update) =>
|
||||
_raw.animateCamera(update);
|
||||
Future<bool?> animateCamera(CameraUpdate update) =>
|
||||
_raw.animateCamera(update.toMapLibre());
|
||||
|
||||
/// Instantly moves the camera to the given [update].
|
||||
Future<bool?> moveCamera(mgl.CameraUpdate update) => _raw.moveCamera(update);
|
||||
Future<bool?> moveCamera(CameraUpdate update) =>
|
||||
_raw.moveCamera(update.toMapLibre());
|
||||
|
||||
/// Returns the current [mgl.CameraPosition] of the map.
|
||||
mgl.CameraPosition? get cameraPosition => _raw.cameraPosition;
|
||||
/// Returns the current [CameraPosition] of the map.
|
||||
CameraPosition? get cameraPosition => _raw.cameraPosition != null
|
||||
? CameraPosition.fromMapLibre(_raw.cameraPosition!)
|
||||
: null;
|
||||
|
||||
/// Returns the current zoom level.
|
||||
Future<double> getZoomLevel() async => _raw.cameraPosition?.zoom ?? 14.0;
|
||||
@@ -91,17 +144,20 @@ class IntaleqMapController {
|
||||
|
||||
/// Register a custom image into the map.
|
||||
/// Required before using [InlqBitmap.fromBytes] or custom style images.
|
||||
Future<void> addImage(String imageId, Uint8List bytes) =>
|
||||
_raw.addImage(imageId, bytes);
|
||||
|
||||
// ── Markers ────────────────────────────────────────────────
|
||||
Future<void> addImage(String imageId, Uint8List bytes) async {
|
||||
await _raw.addImage(imageId, bytes);
|
||||
_loadedImages.add(imageId);
|
||||
}
|
||||
|
||||
/// Adds a single [Marker] to the map and returns its MapLibre handle.
|
||||
Future<mgl.Symbol> addMarker(Marker marker) async {
|
||||
await _loadBitmapIfNeeded(marker.icon);
|
||||
final symbol = await _raw.addSymbol(marker.toSymbolOptions());
|
||||
_symbols[marker.markerId] = symbol;
|
||||
_symbolToMarker[symbol.id] = marker;
|
||||
|
||||
// Ensure collision defaults apply (symbol layer is created lazily by MapLibre)
|
||||
await applyMarkerVisibilityDefaults();
|
||||
|
||||
return symbol;
|
||||
}
|
||||
|
||||
@@ -121,6 +177,44 @@ class IntaleqMapController {
|
||||
await _raw.removeSymbol(symbol);
|
||||
}
|
||||
|
||||
// ── User-location puck (imperative, high-frequency) ────────
|
||||
|
||||
/// Places or moves the single "user location" puck.
|
||||
///
|
||||
/// Unlike the declarative [markers] set, this updates one native symbol
|
||||
/// in place — the correct pattern for a marker that moves on every GPS or
|
||||
/// animation frame (a vehicle/heading puck). It avoids rebuilding the
|
||||
/// Flutter tree and the whole-set diff, which otherwise makes a
|
||||
/// continuously-moving marker flicker or disappear.
|
||||
///
|
||||
/// Safe to call at up to display refresh rate: overlapping calls are
|
||||
/// coalesced (a call that arrives while another is still in flight is
|
||||
/// dropped, and the next tick supplies fresh coordinates). The puck is
|
||||
/// re-created automatically after a style reload.
|
||||
Future<void> setUserMarker(Marker marker) async {
|
||||
if (_userSymbolBusy) return;
|
||||
_userSymbolBusy = true;
|
||||
try {
|
||||
await _loadBitmapIfNeeded(marker.icon);
|
||||
final symbol = _userSymbol;
|
||||
if (symbol == null) {
|
||||
_userSymbol = await _raw.addSymbol(marker.toSymbolOptions());
|
||||
} else {
|
||||
await _raw.updateSymbol(symbol, marker.toSymbolOptions());
|
||||
}
|
||||
} finally {
|
||||
_userSymbolBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the user-location puck, if present.
|
||||
Future<void> clearUserMarker() async {
|
||||
final symbol = _userSymbol;
|
||||
if (symbol == null) return;
|
||||
_userSymbol = null;
|
||||
await _raw.removeSymbol(symbol);
|
||||
}
|
||||
|
||||
// ── Polylines ──────────────────────────────────────────────
|
||||
|
||||
Future<mgl.Line> addPolyline(Polyline polyline) async {
|
||||
@@ -295,9 +389,9 @@ class IntaleqMapController {
|
||||
|
||||
/// Searches for places using the Intaleq Geocoding API.
|
||||
Future<List<dynamic>> searchPlaces(String query) async {
|
||||
final uri = Uri.https('map-saas.intaleq.com', '/v1/geocoding/search', {
|
||||
final uri = Uri.https('map-saas.intaleqapp.com', '/api/geocoding/search', {
|
||||
'q': query,
|
||||
'key': _apiKey,
|
||||
'api_key': _apiKey,
|
||||
});
|
||||
final res = await http.get(uri);
|
||||
if (res.statusCode == 200) return jsonDecode(res.body) as List<dynamic>;
|
||||
@@ -306,10 +400,10 @@ class IntaleqMapController {
|
||||
|
||||
/// Reverse geocodes a [LatLng] to a place description.
|
||||
Future<Map<String, dynamic>> reverseGeocode(mgl.LatLng position) async {
|
||||
final uri = Uri.https('map-saas.intaleq.com', '/v1/geocoding/reverse', {
|
||||
final uri = Uri.https('map-saas.intaleqapp.com', '/api/geocoding/reverse', {
|
||||
'lat': position.latitude.toString(),
|
||||
'lng': position.longitude.toString(),
|
||||
'key': _apiKey,
|
||||
'api_key': _apiKey,
|
||||
});
|
||||
final res = await http.get(uri);
|
||||
if (res.statusCode == 200)
|
||||
@@ -320,17 +414,22 @@ class IntaleqMapController {
|
||||
/// Fetches a route between [origin] and [destination].
|
||||
///
|
||||
/// [profile] is one of: `driving`, `cycling`, `walking`.
|
||||
/// [steps] when true, returns turn-by-turn navigation steps.
|
||||
/// Returns the raw Intaleq Routing API response.
|
||||
Future<Map<String, dynamic>> getDirections(
|
||||
mgl.LatLng origin,
|
||||
mgl.LatLng destination, {
|
||||
String profile = 'driving',
|
||||
bool steps = true,
|
||||
}) async {
|
||||
final uri = Uri.https('map-saas.intaleq.com', '/v1/routing/route', {
|
||||
'start': '${origin.longitude},${origin.latitude}',
|
||||
'end': '${destination.longitude},${destination.latitude}',
|
||||
final uri = Uri.https('map-saas.intaleqapp.com', '/api/maps/route', {
|
||||
'fromLat': origin.latitude.toString(),
|
||||
'fromLng': origin.longitude.toString(),
|
||||
'toLat': destination.latitude.toString(),
|
||||
'toLng': destination.longitude.toString(),
|
||||
'profile': profile,
|
||||
'key': _apiKey,
|
||||
'steps': steps.toString(),
|
||||
'api_key': _apiKey,
|
||||
});
|
||||
final res = await http.get(uri);
|
||||
if (res.statusCode == 200)
|
||||
@@ -338,6 +437,42 @@ class IntaleqMapController {
|
||||
throw Exception('Routing request failed: ${res.statusCode}');
|
||||
}
|
||||
|
||||
/// High-level helper to fetch and draw a route on the map.
|
||||
///
|
||||
/// Returns the [PolylineId] of the drawn route, or null if it failed.
|
||||
Future<PolylineId?> drawRoute({
|
||||
required mgl.LatLng origin,
|
||||
required mgl.LatLng destination,
|
||||
String profile = 'driving',
|
||||
Color color = const Color(0xFF2196F3),
|
||||
double width = 5.0,
|
||||
}) async {
|
||||
try {
|
||||
final data = await getDirections(origin, destination, profile: profile);
|
||||
if (data['status'] == 'success' && data['data'] != null) {
|
||||
final List<dynamic> points = data['data']['points'];
|
||||
final List<mgl.LatLng> path = points
|
||||
.map((p) => mgl.LatLng(p['lat'] as double, p['lng'] as double))
|
||||
.toList();
|
||||
|
||||
final polylineId =
|
||||
PolylineId('route_${DateTime.now().millisecondsSinceEpoch}');
|
||||
final polyline = Polyline(
|
||||
polylineId: polylineId,
|
||||
points: path,
|
||||
color: color,
|
||||
width: width,
|
||||
);
|
||||
|
||||
await addPolyline(polyline);
|
||||
return polylineId;
|
||||
}
|
||||
} catch (e) {
|
||||
print('IntaleqMapController.drawRoute error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Bitmap loader helper ───────────────────────────────────
|
||||
|
||||
final Set<String> _loadedImages = {};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as mgl;
|
||||
import 'intaleq_map_controller.dart';
|
||||
import 'styles.dart';
|
||||
import 'offline_service.dart';
|
||||
import 'models/geometry.dart';
|
||||
import 'models/types.dart';
|
||||
|
||||
@@ -40,9 +42,11 @@ class IntaleqMap extends StatefulWidget {
|
||||
this.polylines = const {},
|
||||
this.circles = const {},
|
||||
this.polygons = const {},
|
||||
this.onMapCreated,
|
||||
this.onTap,
|
||||
|
||||
this.onLongPress,
|
||||
this.onMapCreated,
|
||||
this.onStyleLoaded,
|
||||
this.onTap,
|
||||
this.onCameraMove,
|
||||
this.onCameraMoveStarted,
|
||||
this.onCameraIdle,
|
||||
@@ -54,6 +58,7 @@ class IntaleqMap extends StatefulWidget {
|
||||
this.scrollGesturesEnabled = true,
|
||||
this.tiltGesturesEnabled = true,
|
||||
this.zoomGesturesEnabled = true,
|
||||
this.autoCache = true,
|
||||
this.minMaxZoomPreference = MinMaxZoomPreference.unbounded,
|
||||
this.cameraTargetBounds = CameraTargetBounds.unbounded,
|
||||
});
|
||||
@@ -64,7 +69,7 @@ class IntaleqMap extends StatefulWidget {
|
||||
final String apiKey;
|
||||
|
||||
/// Starting camera position (target + zoom).
|
||||
final mgl.CameraPosition initialCameraPosition;
|
||||
final CameraPosition initialCameraPosition;
|
||||
|
||||
// ── Map style ──────────────────────────────────────────────
|
||||
|
||||
@@ -74,6 +79,9 @@ class IntaleqMap extends StatefulWidget {
|
||||
/// Override with a custom MapLibre style URL.
|
||||
final String? styleUrl;
|
||||
|
||||
/// Automatically download tiles around the camera center when idle.
|
||||
final bool autoCache;
|
||||
|
||||
// ── Overlays (declarative — mirrors GoogleMap) ─────────────
|
||||
|
||||
final Set<Marker> markers;
|
||||
@@ -86,6 +94,10 @@ class IntaleqMap extends StatefulWidget {
|
||||
/// Called once the map is ready. Use [IntaleqMapController] for all operations.
|
||||
final MapCreatedCallback? onMapCreated;
|
||||
|
||||
/// Note: If this callback returns a Future, the widget will await it before
|
||||
/// adding declarative markers and polylines.
|
||||
final Function()? onStyleLoaded;
|
||||
|
||||
// ── Interaction callbacks ──────────────────────────────────
|
||||
|
||||
/// Called when the user taps the map (not on a marker).
|
||||
@@ -128,10 +140,48 @@ class IntaleqMap extends StatefulWidget {
|
||||
|
||||
class _IntaleqMapState extends State<IntaleqMap> {
|
||||
IntaleqMapController? _controller;
|
||||
bool _isCameraMoving = false;
|
||||
String? _styleString;
|
||||
bool _isLoadingStyle = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStyle();
|
||||
}
|
||||
|
||||
Future<void> _loadStyle() async {
|
||||
try {
|
||||
final url = _resolvedStyleUrl;
|
||||
print("🔥 [IntaleqMap] Resolving style: $url");
|
||||
|
||||
if (url.startsWith('asset://')) {
|
||||
final assetPath = url.replaceFirst('asset://', '');
|
||||
_styleString = await rootBundle.loadString(assetPath);
|
||||
print("🔥 [IntaleqMap] Style loaded from asset string: ${assetPath.split('/').last}");
|
||||
} else {
|
||||
_styleString = url;
|
||||
print("🔥 [IntaleqMap] Using remote style URL: $url");
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ [IntaleqMap] Failed to load style: $e");
|
||||
_styleString = 'about:blank';
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingStyle = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(IntaleqMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.mapType != widget.mapType ||
|
||||
oldWidget.styleUrl != widget.styleUrl) {
|
||||
_loadStyle();
|
||||
}
|
||||
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null) return;
|
||||
|
||||
@@ -144,15 +194,16 @@ class _IntaleqMapState extends State<IntaleqMap> {
|
||||
|
||||
String get _resolvedStyleUrl {
|
||||
if (widget.styleUrl != null) return widget.styleUrl!;
|
||||
// Default to local assets for performance and offline support
|
||||
return switch (widget.mapType) {
|
||||
IntaleqMapType.normal => IntaleqStyles.obsidian(widget.apiKey),
|
||||
IntaleqMapType.light => IntaleqStyles.light(widget.apiKey),
|
||||
IntaleqMapType.normal => 'asset://packages/intaleq_maps/assets/style_dark.json',
|
||||
IntaleqMapType.light => 'asset://packages/intaleq_maps/assets/style.json',
|
||||
IntaleqMapType.satellite => IntaleqStyles.satellite(widget.apiKey),
|
||||
IntaleqMapType.none => 'about:blank',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _onMapCreated(mgl.MaplibreMapController rawCtrl) async {
|
||||
Future<void> _onMapCreated(mgl.MapLibreMapController rawCtrl) async {
|
||||
// Wire up tap routing before handing the controller to the caller.
|
||||
rawCtrl.onSymbolTapped.add(_onSymbolTapped);
|
||||
rawCtrl.onLineTapped.add(_onLineTapped);
|
||||
@@ -162,13 +213,6 @@ class _IntaleqMapState extends State<IntaleqMap> {
|
||||
apiKey: widget.apiKey,
|
||||
);
|
||||
_controller = ctrl;
|
||||
|
||||
// Render the initial overlay sets.
|
||||
for (final m in widget.markers) await ctrl.addMarker(m);
|
||||
for (final p in widget.polylines) await ctrl.addPolyline(p);
|
||||
for (final c in widget.circles) await ctrl.addCircle(c);
|
||||
for (final g in widget.polygons) await ctrl.addPolygon(g);
|
||||
|
||||
widget.onMapCreated?.call(ctrl);
|
||||
}
|
||||
|
||||
@@ -177,25 +221,64 @@ class _IntaleqMapState extends State<IntaleqMap> {
|
||||
|
||||
void _onLineTapped(mgl.Line line) => _controller?.onLineTapped(line);
|
||||
|
||||
Future<void> _onStyleLoaded() async {
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null) return;
|
||||
|
||||
await ctrl.onStyleLoaded();
|
||||
final callbackResult = widget.onStyleLoaded?.call();
|
||||
if (callbackResult is Future) {
|
||||
await callbackResult;
|
||||
}
|
||||
|
||||
// Re-render everything from the current declarative sets.
|
||||
// This ensures overlays persist across style changes (Dark/Light mode)
|
||||
// and during certain zoom/camera events that trigger style reloads.
|
||||
for (final m in widget.markers) await ctrl.addMarker(m);
|
||||
for (final p in widget.polylines) await ctrl.addPolyline(p);
|
||||
for (final c in widget.circles) await ctrl.addCircle(c);
|
||||
for (final g in widget.polygons) await ctrl.addPolygon(g);
|
||||
|
||||
// Apply defaults AFTER markers have initialized the symbol layer
|
||||
await ctrl.applyMarkerVisibilityDefaults();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoadingStyle) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return mgl.MaplibreMap(
|
||||
styleString: _resolvedStyleUrl,
|
||||
initialCameraPosition: widget.initialCameraPosition,
|
||||
styleString: _styleString!,
|
||||
initialCameraPosition: widget.initialCameraPosition.toMapLibre(),
|
||||
onMapCreated: _onMapCreated,
|
||||
onStyleLoadedCallback: _onStyleLoaded,
|
||||
onMapClick: widget.onTap != null
|
||||
? (point, latlng) => widget.onTap!(latlng)
|
||||
: null,
|
||||
onMapLongClick: widget.onLongPress != null
|
||||
? (point, latlng) => widget.onLongPress!(latlng)
|
||||
: null,
|
||||
onCameraIdle: widget.onCameraIdle,
|
||||
onCameraIdle: () {
|
||||
_isCameraMoving = false;
|
||||
if (widget.autoCache) {
|
||||
final pos = _controller?.cameraPosition;
|
||||
if (pos != null) {
|
||||
IntaleqOfflineService.instance.downloadRegion(
|
||||
pos.target,
|
||||
styleUrl: _resolvedStyleUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
widget.onCameraIdle?.call();
|
||||
},
|
||||
onCameraTrackingChanged: null,
|
||||
myLocationEnabled: widget.myLocationEnabled,
|
||||
myLocationRenderMode: widget.myLocationEnabled
|
||||
? mgl.MyLocationRenderMode.NORMAL
|
||||
: mgl.MyLocationRenderMode.NORMAL,
|
||||
myLocationTrackingMode: mgl.MyLocationTrackingMode.None,
|
||||
? mgl.MyLocationRenderMode.normal
|
||||
: mgl.MyLocationRenderMode.normal,
|
||||
myLocationTrackingMode: mgl.MyLocationTrackingMode.none,
|
||||
compassEnabled: widget.compassEnabled,
|
||||
rotateGesturesEnabled: widget.rotateGesturesEnabled,
|
||||
scrollGesturesEnabled: widget.scrollGesturesEnabled,
|
||||
@@ -208,7 +291,17 @@ class _IntaleqMapState extends State<IntaleqMap> {
|
||||
cameraTargetBounds: widget.cameraTargetBounds.bounds != null
|
||||
? mgl.CameraTargetBounds(widget.cameraTargetBounds.bounds!)
|
||||
: mgl.CameraTargetBounds.unbounded,
|
||||
trackCameraPosition: widget.onCameraMove != null,
|
||||
trackCameraPosition:
|
||||
widget.onCameraMove != null ||
|
||||
widget.onCameraMoveStarted != null ||
|
||||
widget.onCameraIdle != null,
|
||||
onCameraMove: (pos) {
|
||||
if (!_isCameraMoving && widget.onCameraMoveStarted != null) {
|
||||
_isCameraMoving = true;
|
||||
widget.onCameraMoveStarted!();
|
||||
}
|
||||
widget.onCameraMove?.call(CameraPosition.fromMapLibre(pos));
|
||||
},
|
||||
onCameraTrackingDismissed: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
import 'dart:ui' show Offset;
|
||||
|
||||
/// Defines a bitmap image for use as a [Marker] icon.
|
||||
///
|
||||
@@ -9,7 +8,6 @@ class InlqBitmap {
|
||||
const InlqBitmap._({
|
||||
required this.mapLibreImageId,
|
||||
this.size,
|
||||
this.offset,
|
||||
this.bytes,
|
||||
this.assetName,
|
||||
});
|
||||
@@ -20,8 +18,6 @@ class InlqBitmap {
|
||||
/// Optional size multiplier (MapLibre iconSize).
|
||||
final double? size;
|
||||
|
||||
/// Optional pixel offset from the anchor point.
|
||||
final Offset? offset;
|
||||
|
||||
/// Raw PNG/JPEG bytes (used with [fromBytes]).
|
||||
final Uint8List? bytes;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:ui' show Offset, Color;
|
||||
import 'package:flutter/foundation.dart' show VoidCallback, ValueChanged;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as mgl;
|
||||
import '../constants/colors.dart';
|
||||
import 'bitmap.dart';
|
||||
import 'info_window.dart';
|
||||
|
||||
@@ -9,46 +8,74 @@ import 'info_window.dart';
|
||||
// ID types (identical contract to google_maps_flutter)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Uniquely identifies a [Marker] on the map.
|
||||
class MarkerId {
|
||||
/// Creates a [MarkerId] with the given [value].
|
||||
const MarkerId(this.value);
|
||||
|
||||
/// The unique string identifier.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is MarkerId && o.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'MarkerId($value)';
|
||||
}
|
||||
|
||||
/// Uniquely identifies a [Polyline] on the map.
|
||||
class PolylineId {
|
||||
/// Creates a [PolylineId] with the given [value].
|
||||
const PolylineId(this.value);
|
||||
|
||||
/// The unique string identifier.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is PolylineId && o.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'PolylineId($value)';
|
||||
}
|
||||
|
||||
/// Uniquely identifies a [Circle] on the map.
|
||||
class CircleId {
|
||||
/// Creates a [CircleId] with the given [value].
|
||||
const CircleId(this.value);
|
||||
|
||||
/// The unique string identifier.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is CircleId && o.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'CircleId($value)';
|
||||
}
|
||||
|
||||
/// Uniquely identifies a [Polygon] on the map.
|
||||
class PolygonId {
|
||||
/// Creates a [PolygonId] with the given [value].
|
||||
const PolygonId(this.value);
|
||||
|
||||
/// The unique string identifier.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is PolygonId && o.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'PolygonId($value)';
|
||||
}
|
||||
@@ -57,7 +84,9 @@ class PolygonId {
|
||||
// Marker (mirrors google_maps_flutter Marker)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A marker that is placed at a specific geographical location on the map.
|
||||
class Marker {
|
||||
/// Creates a [Marker].
|
||||
const Marker({
|
||||
required this.markerId,
|
||||
required this.position,
|
||||
@@ -76,7 +105,10 @@ class Marker {
|
||||
this.onDragEnd,
|
||||
});
|
||||
|
||||
/// Unique identifier for this marker.
|
||||
final MarkerId markerId;
|
||||
|
||||
/// Geographical location of the marker.
|
||||
final mgl.LatLng position;
|
||||
|
||||
/// Opacity of the marker icon, from 0.0 (transparent) to 1.0 (opaque).
|
||||
@@ -165,7 +197,24 @@ class Marker {
|
||||
draggable: draggable,
|
||||
zIndex: zIndex.toInt(),
|
||||
textField: infoWindow.title,
|
||||
iconOffset: icon.offset,
|
||||
textAnchor: 'bottom',
|
||||
textOffset: const Offset(0, -3.0),
|
||||
textSize: 12.0,
|
||||
textColor: (infoWindow.snippet != null &&
|
||||
(infoWindow.snippet == 'start' ||
|
||||
infoWindow.snippet == 'end' ||
|
||||
infoWindow.snippet!.startsWith('stop_'))) ? '#FFFFFF' : '#000000',
|
||||
textHaloColor: infoWindow.snippet == 'start' ? '#4CAF50'
|
||||
: (infoWindow.snippet == 'end' ? '#F44336'
|
||||
: (infoWindow.snippet == 'stop_0' ? '#FF9800' // Orange
|
||||
: (infoWindow.snippet == 'stop_1' ? '#9C27B0' // Purple
|
||||
: '#FFFFFF'))),
|
||||
textHaloWidth: 3.0,
|
||||
// The bundled Intaleq styles serve glyphs from a host that only carries
|
||||
// Noto Sans. Without an explicit font, the annotation layer falls back to
|
||||
// "Open Sans Regular, Arial Unicode MS Regular"; that glyph request 404s
|
||||
// and MapLibre then drops the entire symbol — icon included — on iOS.
|
||||
fontNames: infoWindow.title != null ? const ['Noto Sans Regular'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,27 +228,54 @@ class Marker {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is Marker && o.markerId == markerId;
|
||||
bool operator ==(Object o) =>
|
||||
o is Marker &&
|
||||
o.markerId == markerId &&
|
||||
_latLngEquals(o.position, position) &&
|
||||
o.alpha == alpha &&
|
||||
o.anchor == anchor &&
|
||||
o.draggable == draggable &&
|
||||
o.flat == flat &&
|
||||
o.icon == icon &&
|
||||
o.infoWindow == infoWindow &&
|
||||
o.rotation == rotation &&
|
||||
o.visible == visible &&
|
||||
o.zIndex == zIndex;
|
||||
@override
|
||||
int get hashCode => markerId.hashCode;
|
||||
int get hashCode => Object.hash(
|
||||
markerId,
|
||||
_latLngHash(position),
|
||||
alpha,
|
||||
anchor,
|
||||
draggable,
|
||||
flat,
|
||||
icon,
|
||||
infoWindow,
|
||||
rotation,
|
||||
visible,
|
||||
zIndex,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Polyline (mirrors google_maps_flutter Polyline)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A polyline is a list of segments that join a sequence of [mgl.LatLng] locations.
|
||||
class Polyline {
|
||||
/// Creates a [Polyline].
|
||||
const Polyline({
|
||||
required this.polylineId,
|
||||
required this.points,
|
||||
this.color = const Color(0xFF0D47A1),
|
||||
this.width = 5,
|
||||
this.width = 5.0,
|
||||
this.visible = true,
|
||||
this.zIndex = 0,
|
||||
this.geodesic = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Unique identifier for this polyline.
|
||||
final PolylineId polylineId;
|
||||
|
||||
/// The ordered list of points that make up the polyline.
|
||||
@@ -209,7 +285,7 @@ class Polyline {
|
||||
final Color color;
|
||||
|
||||
/// Line stroke width in screen pixels.
|
||||
final int width;
|
||||
final double width;
|
||||
|
||||
/// Whether the polyline is visible on the map.
|
||||
final bool visible;
|
||||
@@ -225,7 +301,7 @@ class Polyline {
|
||||
Polyline copyWith({
|
||||
List<mgl.LatLng>? points,
|
||||
Color? color,
|
||||
int? width,
|
||||
double? width,
|
||||
bool? visible,
|
||||
int? zIndex,
|
||||
bool? geodesic,
|
||||
@@ -243,40 +319,59 @@ class Polyline {
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal: converts to MapLibre LineOptions.
|
||||
/// Internal: converts to MapLibre LineOptions.
|
||||
mgl.LineOptions toLineOptions() {
|
||||
return mgl.LineOptions(
|
||||
geometry: points,
|
||||
lineColor: _colorToHex(color),
|
||||
lineWidth: width.toDouble(),
|
||||
lineOpacity: visible ? color.opacity : 0.0,
|
||||
lineWidth: width < 5.0 ? 5.0 : width,
|
||||
lineJoin: 'round',
|
||||
lineOpacity: 0.85,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is Polyline && o.polylineId == polylineId;
|
||||
bool operator ==(Object o) =>
|
||||
o is Polyline &&
|
||||
o.polylineId == polylineId &&
|
||||
o.color == color &&
|
||||
o.width == width &&
|
||||
o.visible == visible &&
|
||||
o.zIndex == zIndex &&
|
||||
o.geodesic == geodesic &&
|
||||
_latLngListEquals(o.points, points);
|
||||
@override
|
||||
int get hashCode => polylineId.hashCode;
|
||||
int get hashCode => Object.hash(
|
||||
polylineId,
|
||||
color,
|
||||
width,
|
||||
visible,
|
||||
zIndex,
|
||||
geodesic,
|
||||
Object.hashAll(points.map(_latLngHash)),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Circle (mirrors google_maps_flutter Circle)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A circle on the map surface.
|
||||
class Circle {
|
||||
/// Creates a [Circle].
|
||||
const Circle({
|
||||
required this.circleId,
|
||||
required this.center,
|
||||
required this.radius,
|
||||
this.fillColor = const Color(0x1A0D47A1),
|
||||
this.strokeColor = const Color(0xFF0D47A1),
|
||||
this.strokeWidth = 2,
|
||||
this.strokeWidth = 2.0,
|
||||
this.visible = true,
|
||||
this.zIndex = 0,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Unique identifier for this circle.
|
||||
final CircleId circleId;
|
||||
|
||||
/// Center of the circle.
|
||||
@@ -292,7 +387,7 @@ class Circle {
|
||||
final Color strokeColor;
|
||||
|
||||
/// Stroke width in pixels.
|
||||
final int strokeWidth;
|
||||
final double strokeWidth;
|
||||
|
||||
final bool visible;
|
||||
final int zIndex;
|
||||
@@ -305,7 +400,7 @@ class Circle {
|
||||
double? radius,
|
||||
Color? fillColor,
|
||||
Color? strokeColor,
|
||||
int? strokeWidth,
|
||||
double? strokeWidth,
|
||||
bool? visible,
|
||||
int? zIndex,
|
||||
VoidCallback? onTap,
|
||||
@@ -332,37 +427,58 @@ class Circle {
|
||||
circleRadius:
|
||||
radius / 10, // approximate; use addCircleAccurate for precision
|
||||
circleColor: _colorToHex(fillColor),
|
||||
circleOpacity: visible ? fillColor.opacity : 0.0,
|
||||
circleOpacity: visible ? fillColor.a : 0.0,
|
||||
circleStrokeColor: _colorToHex(strokeColor),
|
||||
circleStrokeWidth: strokeWidth.toDouble(),
|
||||
circleStrokeOpacity: visible ? strokeColor.opacity : 0.0,
|
||||
circleStrokeWidth: strokeWidth,
|
||||
circleStrokeOpacity: visible ? strokeColor.a : 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is Circle && o.circleId == circleId;
|
||||
bool operator ==(Object o) =>
|
||||
o is Circle &&
|
||||
o.circleId == circleId &&
|
||||
_latLngEquals(o.center, center) &&
|
||||
o.radius == radius &&
|
||||
o.fillColor == fillColor &&
|
||||
o.strokeColor == strokeColor &&
|
||||
o.strokeWidth == strokeWidth &&
|
||||
o.visible == visible &&
|
||||
o.zIndex == zIndex;
|
||||
@override
|
||||
int get hashCode => circleId.hashCode;
|
||||
int get hashCode => Object.hash(
|
||||
circleId,
|
||||
_latLngHash(center),
|
||||
radius,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
visible,
|
||||
zIndex,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Polygon (mirrors google_maps_flutter Polygon)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A polygon on the map surface.
|
||||
class Polygon {
|
||||
/// Creates a [Polygon].
|
||||
const Polygon({
|
||||
required this.polygonId,
|
||||
required this.points,
|
||||
this.holes = const [],
|
||||
this.fillColor = const Color(0x1ABDBDBD),
|
||||
this.strokeColor = const Color(0xFF0D47A1),
|
||||
this.strokeWidth = 2,
|
||||
this.strokeWidth = 2.0,
|
||||
this.visible = true,
|
||||
this.zIndex = 0,
|
||||
this.geodesic = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Unique identifier for this polygon.
|
||||
final PolygonId polygonId;
|
||||
|
||||
/// Outer boundary of the polygon.
|
||||
@@ -378,7 +494,7 @@ class Polygon {
|
||||
final Color strokeColor;
|
||||
|
||||
/// Outline stroke width in pixels.
|
||||
final int strokeWidth;
|
||||
final double strokeWidth;
|
||||
|
||||
final bool visible;
|
||||
final int zIndex;
|
||||
@@ -392,7 +508,7 @@ class Polygon {
|
||||
List<List<mgl.LatLng>>? holes,
|
||||
Color? fillColor,
|
||||
Color? strokeColor,
|
||||
int? strokeWidth,
|
||||
double? strokeWidth,
|
||||
bool? visible,
|
||||
int? zIndex,
|
||||
bool? geodesic,
|
||||
@@ -419,15 +535,34 @@ class Polygon {
|
||||
return mgl.FillOptions(
|
||||
geometry: rings,
|
||||
fillColor: _colorToHex(fillColor),
|
||||
fillOpacity: visible ? fillColor.opacity : 0.0,
|
||||
fillOpacity: visible ? fillColor.a : 0.0,
|
||||
fillOutlineColor: _colorToHex(strokeColor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) => o is Polygon && o.polygonId == polygonId;
|
||||
bool operator ==(Object o) =>
|
||||
o is Polygon &&
|
||||
o.polygonId == polygonId &&
|
||||
o.fillColor == fillColor &&
|
||||
o.strokeColor == strokeColor &&
|
||||
o.strokeWidth == strokeWidth &&
|
||||
o.visible == visible &&
|
||||
o.zIndex == zIndex &&
|
||||
o.geodesic == geodesic &&
|
||||
_latLngListEquals(o.points, points) &&
|
||||
_latLngListListEquals(o.holes, holes);
|
||||
@override
|
||||
int get hashCode => polygonId.hashCode;
|
||||
int get hashCode => Object.hash(
|
||||
polygonId,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
visible,
|
||||
zIndex,
|
||||
geodesic,
|
||||
Object.hashAll(points.map(_latLngHash)),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -440,3 +575,37 @@ String _colorToHex(Color color) {
|
||||
'${color.green.toRadixString(16).padLeft(2, '0')}'
|
||||
'${color.blue.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// LatLng value equality
|
||||
//
|
||||
// mgl.LatLng does not override == / hashCode, so two coordinates
|
||||
// with identical lat/lng are NOT equal by default (identity only).
|
||||
// Geometry classes (Marker/Polyline/Circle/Polygon) need real
|
||||
// content equality so that diffMarkers/diffPolylines/etc. can tell
|
||||
// apart "same shape, unchanged" from "same id, moved/redrawn" —
|
||||
// otherwise in-place updates are silently skipped.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
bool _latLngEquals(mgl.LatLng a, mgl.LatLng b) =>
|
||||
identical(a, b) || (a.latitude == b.latitude && a.longitude == b.longitude);
|
||||
|
||||
int _latLngHash(mgl.LatLng p) => Object.hash(p.latitude, p.longitude);
|
||||
|
||||
bool _latLngListEquals(List<mgl.LatLng> a, List<mgl.LatLng> b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!_latLngEquals(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _latLngListListEquals(List<List<mgl.LatLng>> a, List<List<mgl.LatLng>> b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!_latLngListEquals(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:maplibre_gl/maplibre_gl.dart'
|
||||
show LatLngBounds, LatLng, CameraPosition;
|
||||
import 'dart:ui';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as mgl;
|
||||
import '../intaleq_map_controller.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -48,7 +48,7 @@ class MinMaxZoomPreference {
|
||||
class CameraTargetBounds {
|
||||
const CameraTargetBounds(this.bounds);
|
||||
|
||||
final LatLngBounds? bounds;
|
||||
final mgl.LatLngBounds? bounds;
|
||||
|
||||
/// Unbounded (default).
|
||||
static const CameraTargetBounds unbounded = CameraTargetBounds(null);
|
||||
@@ -61,6 +61,152 @@ class CameraTargetBounds {
|
||||
// Typedefs (same names as google_maps_flutter)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Callback when the map is created.
|
||||
typedef MapCreatedCallback = void Function(IntaleqMapController controller);
|
||||
|
||||
/// Generic callback for an argument of type [T].
|
||||
typedef ArgumentCallback<T> = void Function(T argument);
|
||||
|
||||
/// Callback for camera position changes.
|
||||
typedef CameraPositionCallback = void Function(CameraPosition position);
|
||||
|
||||
/// Defines a particular camera position.
|
||||
///
|
||||
/// This class mirrors `CameraPosition` from `google_maps_flutter`.
|
||||
class CameraPosition {
|
||||
/// Creates an immutable representation of the [CameraPosition].
|
||||
const CameraPosition({
|
||||
required this.target,
|
||||
this.bearing = 0.0,
|
||||
this.tilt = 0.0,
|
||||
this.zoom = 0.0,
|
||||
});
|
||||
|
||||
/// The location that the camera is pointing at.
|
||||
final mgl.LatLng target;
|
||||
|
||||
/// The direction that the camera is facing in degrees clockwise from north.
|
||||
final double bearing;
|
||||
|
||||
/// The angle, in degrees, of the camera angle from the perpendicular to the map's surface.
|
||||
final double tilt;
|
||||
|
||||
/// The zoom level of the camera.
|
||||
final double zoom;
|
||||
|
||||
/// Serializes this [CameraPosition] to a JSON-compatible map.
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'target': [target.latitude, target.longitude],
|
||||
'bearing': bearing,
|
||||
'tilt': tilt,
|
||||
'zoom': zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/// Deserializes a [CameraPosition] from a map.
|
||||
static CameraPosition fromMap(Map<String, dynamic> json) {
|
||||
final targetList = json['target'] as List<dynamic>;
|
||||
return CameraPosition(
|
||||
target: mgl.LatLng(targetList[0] as double, targetList[1] as double),
|
||||
bearing: (json['bearing'] as num).toDouble(),
|
||||
tilt: (json['tilt'] as num).toDouble(),
|
||||
zoom: (json['zoom'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal: Converts to MapLibre's [mgl.CameraPosition].
|
||||
mgl.CameraPosition toMapLibre() {
|
||||
return mgl.CameraPosition(
|
||||
target: target,
|
||||
bearing: bearing,
|
||||
tilt: tilt,
|
||||
zoom: zoom,
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal: Creates from MapLibre's [mgl.CameraPosition].
|
||||
static CameraPosition fromMapLibre(mgl.CameraPosition pos) {
|
||||
return CameraPosition(
|
||||
target: pos.target,
|
||||
bearing: pos.bearing,
|
||||
tilt: pos.tilt,
|
||||
zoom: pos.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! CameraPosition) return false;
|
||||
return target == other.target &&
|
||||
bearing == other.bearing &&
|
||||
tilt == other.tilt &&
|
||||
zoom == other.zoom;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(target, bearing, tilt, zoom);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CameraPosition(target: $target, zoom: $zoom, bearing: $bearing, tilt: $tilt)';
|
||||
}
|
||||
|
||||
/// Defines a camera move.
|
||||
///
|
||||
/// This class mirrors `CameraUpdate` from `google_maps_flutter` but
|
||||
/// works with our custom [CameraPosition].
|
||||
class CameraUpdate {
|
||||
CameraUpdate._(this._raw);
|
||||
final mgl.CameraUpdate _raw;
|
||||
|
||||
/// Returns a [CameraUpdate] that moves the camera to the specified [position].
|
||||
static CameraUpdate newCameraPosition(CameraPosition position) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.newCameraPosition(position.toMapLibre()));
|
||||
|
||||
/// Returns a [CameraUpdate] that moves the camera to the specified [latLng].
|
||||
static CameraUpdate newLatLng(mgl.LatLng latLng) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.newLatLng(latLng));
|
||||
|
||||
/// Returns a [CameraUpdate] that moves the camera to the specified [bounds].
|
||||
static CameraUpdate newLatLngBounds(
|
||||
mgl.LatLngBounds bounds, {
|
||||
double left = 0,
|
||||
double top = 0,
|
||||
double right = 0,
|
||||
double bottom = 0,
|
||||
}) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.newLatLngBounds(
|
||||
bounds,
|
||||
left: left,
|
||||
top: top,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
));
|
||||
|
||||
/// Returns a [CameraUpdate] that moves the camera to the specified [latLng] and [zoom].
|
||||
static CameraUpdate newLatLngZoom(mgl.LatLng latLng, double zoom) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.newLatLngZoom(latLng, zoom));
|
||||
|
||||
/// Returns a [CameraUpdate] that scrolls the camera by the specified pixels.
|
||||
static CameraUpdate scrollBy(double dx, double dy) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.scrollBy(dx, dy));
|
||||
|
||||
/// Returns a [CameraUpdate] that zooms the camera by the specified amount.
|
||||
static CameraUpdate zoomBy(double amount, [Offset? focus]) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.zoomBy(amount, focus));
|
||||
|
||||
/// Returns a [CameraUpdate] that zooms in the camera.
|
||||
static CameraUpdate zoomIn() => CameraUpdate._(mgl.CameraUpdate.zoomIn());
|
||||
|
||||
/// Returns a [CameraUpdate] that zooms out the camera.
|
||||
static CameraUpdate zoomOut() => CameraUpdate._(mgl.CameraUpdate.zoomOut());
|
||||
|
||||
/// Returns a [CameraUpdate] that zooms the camera to the specified [zoom] level.
|
||||
static CameraUpdate zoomTo(double zoom) =>
|
||||
CameraUpdate._(mgl.CameraUpdate.zoomTo(zoom));
|
||||
|
||||
/// Internal: Converts to MapLibre's [mgl.CameraUpdate].
|
||||
mgl.CameraUpdate toMapLibre() => _raw;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Service for managing offline map regions in Intaleq Maps.
|
||||
///
|
||||
/// This service allows you to download map tiles for a specific region
|
||||
/// so they can be accessed without an internet connection.
|
||||
class IntaleqOfflineService {
|
||||
static final IntaleqOfflineService instance = IntaleqOfflineService._();
|
||||
IntaleqOfflineService._();
|
||||
|
||||
bool _isDownloading = false;
|
||||
LatLng? _lastDownloadedCenter;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Calculate bounding box for a given center and radius in km.
|
||||
LatLngBounds _calculateBounds(LatLng center, double radiusKm) {
|
||||
const double earthRadius = 6371.0;
|
||||
|
||||
// Latitude degrees per km
|
||||
double latDelta = (radiusKm / earthRadius) * (180 / math.pi);
|
||||
// Longitude degrees per km at given latitude
|
||||
double lngDelta = (radiusKm / earthRadius) *
|
||||
(180 / math.pi) /
|
||||
math.cos(center.latitude * math.pi / 180);
|
||||
|
||||
return LatLngBounds(
|
||||
southwest:
|
||||
LatLng(center.latitude - latDelta, center.longitude - lngDelta),
|
||||
northeast:
|
||||
LatLng(center.latitude + latDelta, center.longitude + lngDelta),
|
||||
);
|
||||
}
|
||||
|
||||
/// Downloads map tiles for a specified radius around a coordinate.
|
||||
///
|
||||
/// [center] is the midpoint of the region.
|
||||
/// [styleUrl] is the style to download (e.g. from IntaleqStyles).
|
||||
/// [radiusKm] defines the extent of the download.
|
||||
void downloadRegion(
|
||||
LatLng center, {
|
||||
required String styleUrl,
|
||||
double radiusKm = 5.0,
|
||||
double minZoom = 6.0,
|
||||
double maxZoom = 16.0,
|
||||
}) {
|
||||
// Debounce: Wait for user to stop moving for 2 seconds before starting download
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(seconds: 2), () async {
|
||||
if (_isDownloading) return;
|
||||
|
||||
// Avoid re-downloading if the user hasn't moved significantly (e.g. > 3km)
|
||||
if (_lastDownloadedCenter != null) {
|
||||
double distance = _calculateDistance(center, _lastDownloadedCenter!);
|
||||
if (distance < 3.0) return;
|
||||
}
|
||||
|
||||
_isDownloading = true;
|
||||
|
||||
try {
|
||||
final bounds = _calculateBounds(center, radiusKm);
|
||||
|
||||
// iOS native crash guard for relative assets - only download remote styles
|
||||
// because local assets are already on disk.
|
||||
if (styleUrl.startsWith('asset://') || (Platform.isIOS && !styleUrl.startsWith('http'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
final regionDefinition = OfflineRegionDefinition(
|
||||
bounds: bounds,
|
||||
mapStyleUrl: styleUrl,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
);
|
||||
|
||||
_lastDownloadedCenter = center;
|
||||
|
||||
// Use maplibre_gl top-level function
|
||||
await downloadOfflineRegion(
|
||||
regionDefinition,
|
||||
metadata: {
|
||||
'name': 'Intaleq-${center.latitude}-${center.longitude}',
|
||||
'downloadDate': DateTime.now().toIso8601String(),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently fail or log in debug
|
||||
print("❌ [OfflineService] Download failed: $e");
|
||||
} finally {
|
||||
_isDownloading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper to calculate distance in km using Haversine formula.
|
||||
double _calculateDistance(LatLng p1, LatLng p2) {
|
||||
var p = 0.017453292519943295;
|
||||
var c = math.cos;
|
||||
var a = 0.5 -
|
||||
c((p2.latitude - p1.latitude) * p) / 2 +
|
||||
c(p1.latitude * p) *
|
||||
c(p2.latitude * p) *
|
||||
(1 - c((p2.longitude - p1.longitude) * p)) /
|
||||
2;
|
||||
return 12742 * math.asin(math.sqrt(a));
|
||||
}
|
||||
|
||||
/// Clears all offline map regions and tiles.
|
||||
Future<void> clearCache() async {
|
||||
try {
|
||||
final List<OfflineRegion> regions = await getListOfRegions();
|
||||
for (var region in regions) {
|
||||
await deleteOfflineRegion(region.id);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,26 @@ class IntaleqStyles {
|
||||
IntaleqStyles._();
|
||||
|
||||
/// Dark premium Obsidian style — the Intaleq default.
|
||||
///
|
||||
/// This style is optimized for readability and a premium look.
|
||||
static String obsidian(String apiKey) =>
|
||||
'https://maps.intaleq.com/styles/obsidian/style.json?key=$apiKey';
|
||||
'https://map-saas.intaleqapp.com/api/maps/style.json?theme=obsidian&api_key=$apiKey';
|
||||
|
||||
/// High-contrast light style.
|
||||
///
|
||||
/// Best for daylight use and printing.
|
||||
static String light(String apiKey) =>
|
||||
'https://maps.intaleq.com/styles/light/style.json?key=$apiKey';
|
||||
'https://map-saas.intaleqapp.com/api/maps/style.json?theme=light&api_key=$apiKey';
|
||||
|
||||
/// Satellite imagery with road labels.
|
||||
///
|
||||
/// High-resolution satellite tiles overlaid with Intaleq vector labels.
|
||||
static String satellite(String apiKey) =>
|
||||
'https://maps.intaleq.com/styles/satellite/style.json?key=$apiKey';
|
||||
'https://map-saas.intaleqapp.com/api/maps/style.json?theme=satellite&api_key=$apiKey';
|
||||
|
||||
/// Path to the local light style asset.
|
||||
static const String localLight = 'assets/style.json';
|
||||
|
||||
/// Path to the local dark style asset.
|
||||
static const String localDark = 'assets/style_dark.json';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user