diff --git a/packages/flutter-sdk/.flutter-plugins-dependencies b/packages/flutter-sdk/.flutter-plugins-dependencies index e90c5c1..2062858 100644 --- a/packages/flutter-sdk/.flutter-plugins-dependencies +++ b/packages/flutter-sdk/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"geolocator_apple","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.13/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.19.0+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"geolocator_android","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.19.0+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"geolocator_apple","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.13/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[],"windows":[{"name":"geolocator_windows","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"geolocator_web","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/geolocator_web-2.2.1/","dependencies":[],"dev_dependency":false},{"name":"maplibre_gl_web","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl_web-0.19.0+2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"maplibre_gl","dependencies":["maplibre_gl_web"]},{"name":"maplibre_gl_web","dependencies":[]}],"date_created":"2026-04-16 16:50:12.143766","version":"3.32.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.19.0+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.19.0+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[],"linux":[],"windows":[],"web":[{"name":"maplibre_gl_web","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl_web-0.19.0+2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"maplibre_gl","dependencies":["maplibre_gl_web"]},{"name":"maplibre_gl_web","dependencies":[]}],"date_created":"2026-04-17 14:04:31.920711","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/packages/flutter-sdk/CHANGELOG.md b/packages/flutter-sdk/CHANGELOG.md index 24c558b..25e0c07 100644 --- a/packages/flutter-sdk/CHANGELOG.md +++ b/packages/flutter-sdk/CHANGELOG.md @@ -1,7 +1,23 @@ +## 2.0.0 + +**Breaking redesign — full Google Maps Flutter API parity.** + +* Replaced imperative `add/removeMarker()` controller methods with a + **declarative `Set` / `Set` / `Set` / `Set`** + API on the widget, identical to `google_maps_flutter`. +* Added `MarkerId`, `PolylineId`, `CircleId`, `PolygonId` typed ID classes. +* Added per-object callbacks: `Marker.onTap`, `Marker.onDragEnd`, `Polyline.onTap`, etc. +* Added `InlqBitmap` (mirrors `BitmapDescriptor`): `defaultMarker`, + `defaultMarkerWithHue`, `fromAsset`, `fromBytes`, `fromStyleImage`. +* Added `InfoWindow` model with `title`, `snippet`, and `onTap`. +* Added `IntaleqMapType` enum (`normal`, `light`, `satellite`, `none`). +* Added `MinMaxZoomPreference` and `CameraTargetBounds`. +* Renamed `getRoute()` → `getDirections()` for API clarity. +* Added `reverseGeocode()` to controller. +* Added `PolylineUtils.encode()` and `PolylineUtils.totalLength()`. +* Added `IntaleqColors` Flutter `Color` constants alongside hex strings. +* Removed `geolocator` and `get`/`latlong2` dependencies (no longer needed). + ## 1.0.0 -* Initial release of the Intaleq Maps Foundation SDK. -* Support for Markers, Polylines, Circles, and Polygons (with holes). -* Integrated Intaleq Routing and Geocoding APIs. -* Built-in premium map styles (Obsidian, Light, Satellite). -* Integrated Polyline decoding utility. +* Initial release. Imperative add/remove API over MapLibre GL. \ No newline at end of file diff --git a/packages/flutter-sdk/README.md b/packages/flutter-sdk/README.md index b156886..f7412f5 100644 --- a/packages/flutter-sdk/README.md +++ b/packages/flutter-sdk/README.md @@ -1,59 +1,187 @@ # Intaleq Maps SDK for Flutter -A premium Flutter SDK for the Intaleq Map Platform, optimized for Jordan, Syria, and the broader region. This SDK provides a simplified, developer-friendly interface over MapLibre GL, with built-in support for Intaleq's Routing and Geocoding APIs. +A **drop-in replacement for `google_maps_flutter`** backed by MapLibre GL, optimized for Jordan, Syria, and the broader MENA region. -## Features +--- -- **Simplified Geometry**: Use `Marker`, `Polyline`, `Circle`, and `Polygon` (with hole support) instead of complex low-level types. -- **Intaleq Routing**: Integrated multi-profile routing API. -- **Intaleq Geocoding**: High-performance place search and reverse geocoding. -- **Branding-Ready**: Built-in Intaleq branding colors and premium map styles (Obsidian, Light, Satellite). -- **Standalone**: No external dependencies required for route decoding. +## Why Intaleq Maps? -## Getting Started +| Feature | `google_maps_flutter` | `intaleq_maps` | +|---|---|---| +| Works in Syria (sanctions) | ❌ | ✅ | +| Self-hosted tiles | ❌ | ✅ | +| MENA-optimised data | ❌ | ✅ | +| Same Flutter API | — | ✅ | +| Offline-ready styles | ❌ | ✅ | -Add the following to your `pubspec.yaml`: +--- + +## Installation ```yaml dependencies: - intaleq_maps: ^1.0.0 + intaleq_maps: ^2.0.0 ``` +--- + +## Migration from `google_maps_flutter` + +The API is intentionally identical. Most migrations are a find-and-replace: + +| Google | Intaleq | +|---|---| +| `GoogleMap(…)` | `IntaleqMap(apiKey: key, …)` | +| `GoogleMapController` | `IntaleqMapController` | +| `BitmapDescriptor` | `InlqBitmap` | +| `MapType.normal` | `IntaleqMapType.normal` | + +Everything else — `Marker`, `Polyline`, `Circle`, `Polygon`, `MarkerId`, `PolylineId`, `InfoWindow`, `CameraUpdate`, `LatLng`, `LatLngBounds` — is **identical**. + +--- + ## Usage -### Initialize the Map +### Basic map ```dart IntaleqMap( - apiKey: "YOUR_INTALEQ_API_KEY", - onMapCreated: (controller) { - // Access the controller here + apiKey: 'YOUR_INTALEQ_API_KEY', + initialCameraPosition: CameraPosition( + target: LatLng(33.5138, 36.2765), // Damascus + zoom: 14, + ), + onMapCreated: (IntaleqMapController controller) { + _controller = controller; }, ) ``` -### Add a Marker +### Declarative markers (exactly like Google Maps Flutter) ```dart -await controller.addMarker(MarkerOptions( - position: LatLng(33.5138, 36.2765), - iconImage: "marker-icon", -)); +// In your State: +Set _markers = {}; + +// Add a marker — just setState: +setState(() { + _markers = { + Marker( + markerId: MarkerId('driver_1'), + position: LatLng(33.5138, 36.2765), + icon: InlqBitmap.fromAsset('assets/icons/car.png'), + infoWindow: InfoWindow( + title: 'Ahmad', + snippet: 'Toyota Corolla · 3 min away', + ), + onTap: () => print('driver tapped'), + ), + }; +}); + +// Pass to widget: +IntaleqMap( + apiKey: _apiKey, + initialCameraPosition: _camera, + markers: _markers, // ← declarative + polylines: _polylines, // ← declarative + circles: _circles, // ← declarative + polygons: _polygons, // ← declarative +) ``` -### Get a Route +### Camera control ```dart -final routeData = await controller.getRoute( - LatLng(33.5138, 36.2765), - LatLng(33.5, 36.3), +// Animate to a position +_controller.animateCamera( + CameraUpdate.newLatLngZoom(LatLng(33.5, 36.2), 15), +); + +// Fit bounds +_controller.animateCamera( + CameraUpdate.newLatLngBounds(bounds, left: 50, right: 50, top: 100, bottom: 100), ); ``` -## Documentation +### Get a route -For full documentation and API reference, visit the [Intaleq Documentation](https://map-dashbord.intaleqapp.com/dashboard.html#docs). +```dart +final route = await _controller.getDirections( + LatLng(33.5138, 36.2765), + LatLng(33.49, 36.30), + profile: 'driving', +); + +// Decode the encoded polyline from the response +final points = PolylineUtils.decode(route['geometry']); + +setState(() { + _polylines = { + Polyline( + polylineId: PolylineId('route'), + points: points, + color: IntaleqColors.routeCyan, + width: 5, + ), + }; +}); +``` + +### Search for places + +```dart +final results = await _controller.searchPlaces('مطار دمشق'); +``` + +### Map types + +```dart +IntaleqMap( + mapType: IntaleqMapType.normal, // Dark Obsidian (default) + // mapType: IntaleqMapType.light, + // mapType: IntaleqMapType.satellite, + ... +) +``` + +--- + +## API Reference + +### `IntaleqMap` widget + +| Property | Type | Default | Description | +|---|---|---|---| +| `apiKey` | `String` | required | Your Intaleq Maps API key | +| `initialCameraPosition` | `CameraPosition` | required | Starting camera | +| `markers` | `Set` | `{}` | Declarative marker set | +| `polylines` | `Set` | `{}` | Declarative polyline set | +| `circles` | `Set` | `{}` | Declarative circle set | +| `polygons` | `Set` | `{}` | Declarative polygon set | +| `onMapCreated` | `MapCreatedCallback?` | — | Fires once map is ready | +| `onTap` | `ArgumentCallback?` | — | Map tap | +| `onLongPress` | `ArgumentCallback?` | — | Map long press | +| `onCameraMove` | `CameraPositionCallback?` | — | Camera movement | +| `mapType` | `IntaleqMapType` | `.normal` | Tile style | +| `myLocationEnabled` | `bool` | `false` | Show user location dot | + +### `IntaleqMapController` + +| Method | Description | +|---|---| +| `animateCamera(update)` | Animated camera move | +| `moveCamera(update)` | Instant camera move | +| `getZoomLevel()` | Current zoom level | +| `getVisibleRegion()` | Current `LatLngBounds` | +| `clearOverlays()` | Remove all markers/lines/circles/polygons | +| `getDirections(origin, dest)` | Intaleq Routing API | +| `searchPlaces(query)` | Intaleq Geocoding API | +| `reverseGeocode(position)` | Reverse geocoding | +| `addImage(id, bytes)` | Register custom marker image | + +--- ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/packages/flutter-sdk/lib/.DS_Store b/packages/flutter-sdk/lib/.DS_Store new file mode 100644 index 0000000..f5c57d7 Binary files /dev/null and b/packages/flutter-sdk/lib/.DS_Store differ diff --git a/packages/flutter-sdk/lib/intaleq_maps.dart b/packages/flutter-sdk/lib/intaleq_maps.dart index 53a68b0..c7de275 100644 --- a/packages/flutter-sdk/lib/intaleq_maps.dart +++ b/packages/flutter-sdk/lib/intaleq_maps.dart @@ -1,10 +1,22 @@ +/// Intaleq Maps SDK for Flutter. +/// +/// A Google Maps Flutter-compatible API backed by MapLibre GL, +/// optimized for Jordan, Syria, and the broader MENA region. library intaleq_maps; -export 'package:maplibre_gl/maplibre_gl.dart' show LatLng, LatLngBounds, CameraUpdate, CameraPosition, Symbol, Line, Circle, Fill; +// Re-export core MapLibre primitives under familiar names +export 'package:maplibre_gl/maplibre_gl.dart' + show LatLng, LatLngBounds, CameraUpdate, CameraPosition; -export 'src/intaleq_map_controller.dart'; +// Public SDK surface export 'src/intaleq_map_widget.dart'; +export 'src/intaleq_map_controller.dart'; export 'src/styles.dart'; -export 'src/models/geometry.dart'; -export 'src/utils/polyline_utils.dart'; export 'src/constants/colors.dart'; +export 'src/utils/polyline_utils.dart'; + +// Models — mirrors google_maps_flutter public types +export 'src/models/geometry.dart'; +export 'src/models/bitmap.dart'; +export 'src/models/info_window.dart'; +export 'src/models/types.dart'; diff --git a/packages/flutter-sdk/lib/src/constants/colors.dart b/packages/flutter-sdk/lib/src/constants/colors.dart index ef47394..62fa49f 100644 --- a/packages/flutter-sdk/lib/src/constants/colors.dart +++ b/packages/flutter-sdk/lib/src/constants/colors.dart @@ -1,16 +1,34 @@ +import 'dart:ui' show Color; + +/// Intaleq brand color palette. +/// +/// CSS hex strings are provided for use with MapLibre layer properties. +/// Flutter [Color] constants are provided for use in Dart/Flutter code. class IntaleqColors { - /// The signature Intaleq Blue color. - static const String primaryBlue = '#0D47A1'; - - /// Cyan used for remaining route lines. - static const String routeCyan = '#00e5ff'; - - /// Gray used for traveled paths or alternatives. - static const String softGray = '#BDBDBD'; - - /// Semi-transparent gray for polygon fills. - static const String fillGray = '#BDBDBD33'; // 20% opacity - + IntaleqColors._(); + + // ── Flutter Color constants ──────────────────────────────── + + /// Primary Intaleq blue. + static const Color primaryBlue = Color(0xFF0D47A1); + + /// Cyan for active / remaining route lines. + static const Color routeCyan = Color(0xFF00E5FF); + + /// Soft gray for traveled paths or alternative routes. + static const Color softGray = Color(0xFFBDBDBD); + + /// Semi-transparent gray for polygon fills (20 % opacity). + static const Color fillGray = Color(0x33BDBDBD); + /// Red for error or stop states. - static const String alertRed = '#ba1a1a'; + static const Color alertRed = Color(0xFFBA1A1A); + + // ── MapLibre hex strings ─────────────────────────────────── + + static const String primaryBlueHex = '#0D47A1'; + static const String routeCyanHex = '#00e5ff'; + static const String softGrayHex = '#BDBDBD'; + static const String fillGrayHex = '#BDBDBD33'; + static const String alertRedHex = '#ba1a1a'; } diff --git a/packages/flutter-sdk/lib/src/intaleq_map_controller.dart b/packages/flutter-sdk/lib/src/intaleq_map_controller.dart index 2382728..4dd1efc 100644 --- a/packages/flutter-sdk/lib/src/intaleq_map_controller.dart +++ b/packages/flutter-sdk/lib/src/intaleq_map_controller.dart @@ -1,105 +1,363 @@ import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter/services.dart' show rootBundle; import 'package:http/http.dart' as http; -import 'package:maplibre_gl/maplibre_gl.dart' hide CircleOptions; +import 'package:maplibre_gl/maplibre_gl.dart' as mgl; import 'models/geometry.dart'; +import 'models/bitmap.dart'; +/// Controls a live [IntaleqMap] widget. +/// +/// Obtain a reference via the [IntaleqMap.onMapCreated] callback. +/// This API mirrors [GoogleMapController] from `google_maps_flutter`. class IntaleqMapController { - final MaplibreMapController mapController; - final String apiKey; + IntaleqMapController._({ + required mgl.MaplibreMapController raw, + required String apiKey, + }) : _raw = raw, + _apiKey = apiKey; - IntaleqMapController({ - required this.mapController, - required this.apiKey, - }); + final mgl.MaplibreMapController _raw; + final String _apiKey; - // --- API Integrations --- + // ── Internal object registries ───────────────────────────── - /// Search for places using Intaleq Geocoding API - Future> search(String query) async { - final response = await http.get( - Uri.parse('https://map-saas.intaleq.com/v1/geocoding/search?q=$query&key=$apiKey'), - ); - if (response.statusCode == 200) { - return json.decode(response.body); + /// Maps our [MarkerId] → the live MapLibre [mgl.Symbol]. + final Map _symbols = {}; + + /// Reverse map: MapLibre symbol id → our [Marker] (for tap routing). + final Map _symbolToMarker = {}; + + /// Maps our [PolylineId] → live MapLibre [mgl.Line]. + final Map _lines = {}; + final Map _lineToPolyline = {}; + + /// Maps our [CircleId] → live MapLibre [mgl.Circle]. + final Map _circles = {}; + + /// Maps our [PolygonId] → live MapLibre [mgl.Fill]. + final Map _fills = {}; + + // ── Factory / init ───────────────────────────────────────── + + static Future create({ + required mgl.MaplibreMapController raw, + required String apiKey, + }) async { + final ctrl = IntaleqMapController._(raw: raw, apiKey: apiKey); + await ctrl._registerDefaultImages(); + return ctrl; + } + + /// Pre-loads built-in Intaleq marker images into the map style. + Future _registerDefaultImages() async { + try { + final bytes = await rootBundle + .load('packages/intaleq_maps/assets/markers/default.png'); + await _raw.addImage('intaleq-marker-default', bytes.buffer.asUint8List()); + } catch (_) { + // Asset not found — developer can register their own via addImage(). } - throw Exception('Failed to search'); } - /// Get route using Intaleq Routing API - Future> getRoute(LatLng start, LatLng end, {String profile = 'driving'}) async { - final response = await http.get( - Uri.parse( - 'https://map-saas.intaleq.com/v1/routing/route?start=${start.longitude},${start.latitude}&end=${end.longitude},${end.latitude}&profile=$profile&key=$apiKey', - ), - ); - if (response.statusCode == 200) { - return json.decode(response.body); + // ── Camera (same API as GoogleMapController) ────────────── + + /// Animates the camera to the given [update]. + Future animateCamera(mgl.CameraUpdate update) => + _raw.animateCamera(update); + + /// Instantly moves the camera to the given [update]. + Future moveCamera(mgl.CameraUpdate update) => _raw.moveCamera(update); + + /// Returns the current [mgl.CameraPosition] of the map. + mgl.CameraPosition? get cameraPosition => _raw.cameraPosition; + + /// Returns the current zoom level. + Future getZoomLevel() async => _raw.cameraPosition?.zoom ?? 14.0; + + /// Returns the [mgl.LatLng] visible at screen coordinate [point]. + Future getLatLng(Point point) => + _raw.toLatLng(Point(point.x, point.y)); + + /// Returns the screen coordinate of the given [latLng]. + Future> getScreenCoordinate(mgl.LatLng latLng) => + _raw.toScreenLocation(latLng); + + /// Returns the current visible region as [mgl.LatLngBounds]. + Future getVisibleRegion() => _raw.getVisibleRegion(); + + // ── Map images ───────────────────────────────────────────── + + /// Register a custom image into the map. + /// Required before using [InlqBitmap.fromBytes] or custom style images. + Future addImage(String imageId, Uint8List bytes) => + _raw.addImage(imageId, bytes); + + // ── Markers ──────────────────────────────────────────────── + + /// Adds a single [Marker] to the map and returns its MapLibre handle. + Future addMarker(Marker marker) async { + await _loadBitmapIfNeeded(marker.icon); + final symbol = await _raw.addSymbol(marker.toSymbolOptions()); + _symbols[marker.markerId] = symbol; + _symbolToMarker[symbol.id] = marker; + return symbol; + } + + /// Updates an existing marker's position / appearance. + Future _updateMarker(Marker marker) async { + final symbol = _symbols[marker.markerId]; + if (symbol == null) return; + await _raw.updateSymbol(symbol, marker.toSymbolOptions()); + _symbolToMarker[symbol.id] = marker; + } + + /// Removes a marker by its [MarkerId]. + Future _removeMarker(MarkerId id) async { + final symbol = _symbols.remove(id); + if (symbol == null) return; + _symbolToMarker.remove(symbol.id); + await _raw.removeSymbol(symbol); + } + + // ── Polylines ────────────────────────────────────────────── + + Future addPolyline(Polyline polyline) async { + final line = await _raw.addLine(polyline.toLineOptions()); + _lines[polyline.polylineId] = line; + _lineToPolyline[line.id] = polyline; + return line; + } + + Future _updatePolyline(Polyline polyline) async { + final line = _lines[polyline.polylineId]; + if (line == null) return; + await _raw.updateLine(line, polyline.toLineOptions()); + _lineToPolyline[line.id] = polyline; + } + + Future _removePolyline(PolylineId id) async { + final line = _lines.remove(id); + if (line == null) return; + _lineToPolyline.remove(line.id); + await _raw.removeLine(line); + } + + // ── Circles ──────────────────────────────────────────────── + + Future addCircle(Circle circle) async { + final c = await _raw.addCircle(circle.toCircleOptions()); + _circles[circle.circleId] = c; + } + + Future _updateCircle(Circle circle) async { + final c = _circles[circle.circleId]; + if (c == null) return; + await _raw.updateCircle(c, circle.toCircleOptions()); + } + + Future _removeCircle(CircleId id) async { + final c = _circles.remove(id); + if (c == null) return; + await _raw.removeCircle(c); + } + + // ── Polygons ─────────────────────────────────────────────── + + Future addPolygon(Polygon polygon) async { + final fill = await _raw.addFill(polygon.toFillOptions()); + _fills[polygon.polygonId] = fill; + } + + Future _updatePolygon(Polygon polygon) async { + final fill = _fills[polygon.polygonId]; + if (fill == null) return; + await _raw.updateFill(fill, polygon.toFillOptions()); + } + + Future _removePolygon(PolygonId id) async { + final fill = _fills.remove(id); + if (fill == null) return; + await _raw.removeFill(fill); + } + + // ── Tap routing ──────────────────────────────────────────── + + void onSymbolTapped(mgl.Symbol symbol) { + final marker = _symbolToMarker[symbol.id]; + if (marker == null) return; + marker.onTap?.call(); + } + + void onLineTapped(mgl.Line line) { + _lineToPolyline[line.id]?.onTap?.call(); + } + + // ── Diff helpers (called from widget on setState) ────────── + + Future diffMarkers( + Set oldSet, + Set newSet, + ) async { + final oldMap = {for (final m in oldSet) m.markerId: m}; + final newMap = {for (final m in newSet) m.markerId: m}; + + // Remove deleted + for (final id in oldMap.keys) { + if (!newMap.containsKey(id)) await _removeMarker(id); } - throw Exception('Failed to fetch route'); - } - - // --- Friendly Abstractions (Transformation Layer) --- - - /// Add a marker (friendly wrapper for addSymbol) - Future addMarker(MarkerOptions options) async { - return await mapController.addSymbol(options.toSymbolOptions()); - } - - /// Add multiple markers - Future> addMarkers(List optionsList) async { - final symbols = []; - for (var options in optionsList) { - symbols.add(await addMarker(options)); + // Add new + for (final entry in newMap.entries) { + if (!oldMap.containsKey(entry.key)) { + await addMarker(entry.value); + } else if (oldMap[entry.key] != entry.value) { + // Update changed (position, icon, etc.) + await _updateMarker(entry.value); + } } - return symbols; } - /// Remove a marker - Future removeMarker(Symbol marker) async { - await mapController.removeSymbol(marker); + Future diffPolylines( + Set oldSet, + Set newSet, + ) async { + final oldMap = {for (final p in oldSet) p.polylineId: p}; + final newMap = {for (final p in newSet) p.polylineId: p}; + + for (final id in oldMap.keys) { + if (!newMap.containsKey(id)) await _removePolyline(id); + } + for (final entry in newMap.entries) { + if (!oldMap.containsKey(entry.key)) { + await addPolyline(entry.value); + } else if (oldMap[entry.key] != entry.value) { + await _updatePolyline(entry.value); + } + } } - /// Add a polyline (friendly wrapper for addLine) - Future addPolyline(PolylineOptions options) async { - return await mapController.addLine(options.toLineOptions()); + Future diffCircles( + Set oldSet, + Set newSet, + ) async { + final oldMap = {for (final c in oldSet) c.circleId: c}; + final newMap = {for (final c in newSet) c.circleId: c}; + + for (final id in oldMap.keys) { + if (!newMap.containsKey(id)) await _removeCircle(id); + } + for (final entry in newMap.entries) { + if (!oldMap.containsKey(entry.key)) { + await addCircle(entry.value); + } else if (oldMap[entry.key] != entry.value) { + await _updateCircle(entry.value); + } + } } - /// Remove a polyline - Future removePolyline(Line polyline) async { - await mapController.removeLine(polyline); + Future diffPolygons( + Set oldSet, + Set newSet, + ) async { + final oldMap = {for (final p in oldSet) p.polygonId: p}; + final newMap = {for (final p in newSet) p.polygonId: p}; + + for (final id in oldMap.keys) { + if (!newMap.containsKey(id)) await _removePolygon(id); + } + for (final entry in newMap.entries) { + if (!oldMap.containsKey(entry.key)) { + await addPolygon(entry.value); + } else if (oldMap[entry.key] != entry.value) { + await _updatePolygon(entry.value); + } + } } - /// Add a circle - Future addCircle(CircleOptions options) async { - return await mapController.addCircle(options.toMapLibreOptions()); + // ── Clear all ────────────────────────────────────────────── + + /// Removes every marker, polyline, circle, and polygon from the map. + Future clearOverlays() async { + await _raw.clearSymbols(); + await _raw.clearLines(); + await _raw.clearCircles(); + await _raw.clearFills(); + _symbols.clear(); + _symbolToMarker.clear(); + _lines.clear(); + _lineToPolyline.clear(); + _circles.clear(); + _fills.clear(); } - /// Remove a circle - Future removeCircle(Circle circle) async { - await mapController.removeCircle(circle); + // ── Intaleq API integrations ─────────────────────────────── + + /// Searches for places using the Intaleq Geocoding API. + Future> searchPlaces(String query) async { + final uri = Uri.https('map-saas.intaleq.com', '/v1/geocoding/search', { + 'q': query, + 'key': _apiKey, + }); + final res = await http.get(uri); + if (res.statusCode == 200) return jsonDecode(res.body) as List; + throw Exception('Geocoding request failed: ${res.statusCode}'); } - /// Add a polygon (Fill) - Future addPolygon(PolygonOptions options) async { - return await mapController.addFill(options.toFillOptions()); + /// Reverse geocodes a [LatLng] to a place description. + Future> reverseGeocode(mgl.LatLng position) async { + final uri = Uri.https('map-saas.intaleq.com', '/v1/geocoding/reverse', { + 'lat': position.latitude.toString(), + 'lng': position.longitude.toString(), + 'key': _apiKey, + }); + final res = await http.get(uri); + if (res.statusCode == 200) + return jsonDecode(res.body) as Map; + throw Exception('Reverse geocoding failed: ${res.statusCode}'); } - /// Remove a polygon - Future removePolygon(Fill polygon) async { - await mapController.removeFill(polygon); + /// Fetches a route between [origin] and [destination]. + /// + /// [profile] is one of: `driving`, `cycling`, `walking`. + /// Returns the raw Intaleq Routing API response. + Future> getDirections( + mgl.LatLng origin, + mgl.LatLng destination, { + String profile = 'driving', + }) async { + final uri = Uri.https('map-saas.intaleq.com', '/v1/routing/route', { + 'start': '${origin.longitude},${origin.latitude}', + 'end': '${destination.longitude},${destination.latitude}', + 'profile': profile, + 'key': _apiKey, + }); + final res = await http.get(uri); + if (res.statusCode == 200) + return jsonDecode(res.body) as Map; + throw Exception('Routing request failed: ${res.statusCode}'); } - /// Clear all map objects - void clearAll() { - mapController.clearSymbols(); - mapController.clearLines(); - mapController.clearCircles(); - mapController.clearFills(); - } + // ── Bitmap loader helper ─────────────────────────────────── - // --- Camera Controls --- + final Set _loadedImages = {}; - void animateCameraTo(LatLng position, {double? zoom}) { - mapController.animateCamera(CameraUpdate.newLatLngZoom(position, zoom ?? mapController.cameraPosition?.zoom ?? 14.0)); + Future _loadBitmapIfNeeded(InlqBitmap bitmap) async { + final id = bitmap.mapLibreImageId; + if (_loadedImages.contains(id)) return; + + if (bitmap.bytes != null) { + await _raw.addImage(id, bitmap.bytes!); + _loadedImages.add(id); + } else if (bitmap.assetName != null) { + try { + final data = await rootBundle.load(bitmap.assetName!); + await _raw.addImage(id, data.buffer.asUint8List()); + _loadedImages.add(id); + } catch (_) { + // Asset not found — marker will fall back to default. + } + } + // Style-registered images need no loading. } } diff --git a/packages/flutter-sdk/lib/src/intaleq_map_widget.dart b/packages/flutter-sdk/lib/src/intaleq_map_widget.dart index e422901..682c3d0 100644 --- a/packages/flutter-sdk/lib/src/intaleq_map_widget.dart +++ b/packages/flutter-sdk/lib/src/intaleq_map_widget.dart @@ -1,57 +1,215 @@ import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' as mgl; import 'intaleq_map_controller.dart'; import 'styles.dart'; +import 'models/geometry.dart'; +import 'models/types.dart'; -typedef MapCreatedCallback = void Function(IntaleqMapController controller); - +/// A widget displaying an Intaleq map. +/// +/// ## Drop-in replacement for `GoogleMap` +/// +/// ```dart +/// // Before (Google Maps Flutter) +/// GoogleMap( +/// initialCameraPosition: CameraPosition(target: LatLng(33.5, 36.2), zoom: 14), +/// markers: _markers, +/// onMapCreated: (ctrl) => _controller = ctrl, +/// ) +/// +/// // After (Intaleq Maps) +/// IntaleqMap( +/// apiKey: 'YOUR_KEY', +/// initialCameraPosition: CameraPosition(target: LatLng(33.5, 36.2), zoom: 14), +/// markers: _markers, +/// onMapCreated: (ctrl) => _controller = ctrl, +/// ) +/// ``` +/// +/// All overlay collections ([markers], [polylines], [circles], [polygons]) +/// are **declarative**: just call `setState` with a new `Set` and the map +/// will reconcile additions, updates, and removals automatically. class IntaleqMap extends StatefulWidget { - final String apiKey; - final String? styleUrl; - final LatLng initialPosition; - final double initialZoom; - final MapCreatedCallback? onMapCreated; - final bool myLocationEnabled; - const IntaleqMap({ super.key, required this.apiKey, + required this.initialCameraPosition, + this.mapType = IntaleqMapType.normal, this.styleUrl, - this.initialPosition = const LatLng(33.5138, 36.2765), - this.initialZoom = 14.0, + this.markers = const {}, + this.polylines = const {}, + this.circles = const {}, + this.polygons = const {}, this.onMapCreated, + this.onTap, + this.onLongPress, + this.onCameraMove, + this.onCameraMoveStarted, + this.onCameraIdle, this.myLocationEnabled = false, + this.myLocationButtonEnabled = false, + this.zoomControlsEnabled = false, + this.compassEnabled = false, + this.rotateGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.zoomGesturesEnabled = true, + this.minMaxZoomPreference = MinMaxZoomPreference.unbounded, + this.cameraTargetBounds = CameraTargetBounds.unbounded, }); + // ── Required ─────────────────────────────────────────────── + + /// Your Intaleq Maps API key. + final String apiKey; + + /// Starting camera position (target + zoom). + final mgl.CameraPosition initialCameraPosition; + + // ── Map style ────────────────────────────────────────────── + + /// Base tile style. Ignored when [styleUrl] is provided. + final IntaleqMapType mapType; + + /// Override with a custom MapLibre style URL. + final String? styleUrl; + + // ── Overlays (declarative — mirrors GoogleMap) ───────────── + + final Set markers; + final Set polylines; + final Set circles; + final Set polygons; + + // ── Lifecycle callbacks ──────────────────────────────────── + + /// Called once the map is ready. Use [IntaleqMapController] for all operations. + final MapCreatedCallback? onMapCreated; + + // ── Interaction callbacks ────────────────────────────────── + + /// Called when the user taps the map (not on a marker). + final ArgumentCallback? onTap; + + /// Called on a long-press on the map. + final ArgumentCallback? onLongPress; + + /// Called repeatedly while the camera is moving. + final CameraPositionCallback? onCameraMove; + + /// Called when the camera starts moving. + final VoidCallback? onCameraMoveStarted; + + /// Called when the camera becomes idle. + final VoidCallback? onCameraIdle; + + // ── UI controls ──────────────────────────────────────────── + + final bool myLocationEnabled; + final bool myLocationButtonEnabled; + final bool zoomControlsEnabled; + final bool compassEnabled; + + // ── Gesture controls ─────────────────────────────────────── + + final bool rotateGesturesEnabled; + final bool scrollGesturesEnabled; + final bool tiltGesturesEnabled; + final bool zoomGesturesEnabled; + + // ── Camera constraints ───────────────────────────────────── + + final MinMaxZoomPreference minMaxZoomPreference; + final CameraTargetBounds cameraTargetBounds; + @override State createState() => _IntaleqMapState(); } class _IntaleqMapState extends State { - IntaleqMapController? _intaleqController; + IntaleqMapController? _controller; + + @override + void didUpdateWidget(IntaleqMap oldWidget) { + super.didUpdateWidget(oldWidget); + final ctrl = _controller; + if (ctrl == null) return; + + // Reconcile each overlay set when widget rebuilds. + ctrl.diffMarkers(oldWidget.markers, widget.markers); + ctrl.diffPolylines(oldWidget.polylines, widget.polylines); + ctrl.diffCircles(oldWidget.circles, widget.circles); + ctrl.diffPolygons(oldWidget.polygons, widget.polygons); + } + + String get _resolvedStyleUrl { + if (widget.styleUrl != null) return widget.styleUrl!; + return switch (widget.mapType) { + IntaleqMapType.normal => IntaleqStyles.obsidian(widget.apiKey), + IntaleqMapType.light => IntaleqStyles.light(widget.apiKey), + IntaleqMapType.satellite => IntaleqStyles.satellite(widget.apiKey), + IntaleqMapType.none => 'about:blank', + }; + } + + Future _onMapCreated(mgl.MaplibreMapController rawCtrl) async { + // Wire up tap routing before handing the controller to the caller. + rawCtrl.onSymbolTapped.add(_onSymbolTapped); + rawCtrl.onLineTapped.add(_onLineTapped); + + final ctrl = await IntaleqMapController.create( + raw: rawCtrl, + 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); + } + + void _onSymbolTapped(mgl.Symbol symbol) => + _controller?.onSymbolTapped(symbol); + + void _onLineTapped(mgl.Line line) => _controller?.onLineTapped(line); @override Widget build(BuildContext context) { - final style = widget.styleUrl ?? IntaleqStyles.obsidian(widget.apiKey); - - return MaplibreMap( - onMapCreated: (MaplibreMapController controller) { - _intaleqController = IntaleqMapController( - mapController: controller, - apiKey: widget.apiKey, - ); - if (widget.onMapCreated != null) { - widget.onMapCreated!(_intaleqController!); - } - }, - styleString: style, - initialCameraPosition: CameraPosition( - target: widget.initialPosition, - zoom: widget.initialZoom, - ), + return mgl.MaplibreMap( + styleString: _resolvedStyleUrl, + initialCameraPosition: widget.initialCameraPosition, + onMapCreated: _onMapCreated, + onMapClick: widget.onTap != null + ? (point, latlng) => widget.onTap!(latlng) + : null, + onMapLongClick: widget.onLongPress != null + ? (point, latlng) => widget.onLongPress!(latlng) + : null, + onCameraIdle: widget.onCameraIdle, + onCameraTrackingChanged: null, myLocationEnabled: widget.myLocationEnabled, - compassEnabled: false, - trackCameraPosition: true, + myLocationRenderMode: widget.myLocationEnabled + ? mgl.MyLocationRenderMode.NORMAL + : mgl.MyLocationRenderMode.NORMAL, + myLocationTrackingMode: mgl.MyLocationTrackingMode.None, + compassEnabled: widget.compassEnabled, + rotateGesturesEnabled: widget.rotateGesturesEnabled, + scrollGesturesEnabled: widget.scrollGesturesEnabled, + tiltGesturesEnabled: widget.tiltGesturesEnabled, + zoomGesturesEnabled: widget.zoomGesturesEnabled, + minMaxZoomPreference: mgl.MinMaxZoomPreference( + widget.minMaxZoomPreference.minZoom, + widget.minMaxZoomPreference.maxZoom, + ), + cameraTargetBounds: widget.cameraTargetBounds.bounds != null + ? mgl.CameraTargetBounds(widget.cameraTargetBounds.bounds!) + : mgl.CameraTargetBounds.unbounded, + trackCameraPosition: widget.onCameraMove != null, + onCameraTrackingDismissed: null, ); } } diff --git a/packages/flutter-sdk/lib/src/models/bitmap.dart b/packages/flutter-sdk/lib/src/models/bitmap.dart new file mode 100644 index 0000000..7f8bcf1 --- /dev/null +++ b/packages/flutter-sdk/lib/src/models/bitmap.dart @@ -0,0 +1,97 @@ +import 'dart:typed_data' show Uint8List; +import 'dart:ui' show Offset; + +/// Defines a bitmap image for use as a [Marker] icon. +/// +/// This class mirrors `BitmapDescriptor` from `google_maps_flutter`. +/// Internally it resolves to a MapLibre image name or inline bytes. +class InlqBitmap { + const InlqBitmap._({ + required this.mapLibreImageId, + this.size, + this.offset, + this.bytes, + this.assetName, + }); + + /// The image name used in MapLibre style expressions. + final String mapLibreImageId; + + /// 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; + + /// Flutter asset path (used with [fromAsset]). + final String? assetName; + + // ── Built-in defaults ────────────────────────────────────── + + /// The standard blue pin marker (default). + static const InlqBitmap defaultMarker = InlqBitmap._( + mapLibreImageId: 'intaleq-marker-default', + ); + + // Hue constants matching google_maps_flutter + static const double hueRed = 0.0; + static const double hueOrange = 30.0; + static const double hueYellow = 60.0; + static const double hueGreen = 120.0; + static const double hueCyan = 180.0; + static const double hueAzure = 210.0; + static const double hueBlue = 240.0; + static const double hueViolet = 270.0; + static const double hueMagenta = 300.0; + static const double hueRose = 330.0; + + /// A colored pin marker. [hue] is a value from 0–360 (HSV hue). + /// + /// Use the `hue*` constants (e.g. [InlqBitmap.hueBlue]) for clarity. + static InlqBitmap defaultMarkerWithHue(double hue) { + return InlqBitmap._( + mapLibreImageId: 'intaleq-marker-hue-${hue.toInt()}', + size: 1.0, + ); + } + + // ── Custom bitmaps ───────────────────────────────────────── + + /// Load an icon from a Flutter asset bundle. + /// + /// ```dart + /// icon: InlqBitmap.fromAsset('assets/icons/car.png') + /// ``` + static InlqBitmap fromAsset(String assetName, {double? size}) { + return InlqBitmap._( + mapLibreImageId: 'asset:$assetName', + assetName: assetName, + size: size, + ); + } + + /// Load an icon from raw image bytes (e.g. a network image you decoded). + static InlqBitmap fromBytes(Uint8List bytes, {double? size}) { + return InlqBitmap._( + mapLibreImageId: 'bytes:${bytes.hashCode}', + bytes: bytes, + size: size, + ); + } + + /// Reference an image already registered in the MapLibre style. + /// + /// Use this when your map style JSON already defines the sprite image. + static InlqBitmap fromStyleImage(String imageId, {double? size}) { + return InlqBitmap._( + mapLibreImageId: imageId, + size: size, + ); + } + + @override + String toString() => 'InlqBitmap($mapLibreImageId)'; +} diff --git a/packages/flutter-sdk/lib/src/models/geometry.dart b/packages/flutter-sdk/lib/src/models/geometry.dart index 454327c..d7aa116 100644 --- a/packages/flutter-sdk/lib/src/models/geometry.dart +++ b/packages/flutter-sdk/lib/src/models/geometry.dart @@ -1,110 +1,442 @@ -import 'dart:ui'; -import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; +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'; -class MarkerOptions { - final maplibre.LatLng position; - final String? iconImage; - final double? iconSize; - final double? iconRotate; - final String? textField; - final double? textOffset; +// ───────────────────────────────────────────────────────────── +// ID types (identical contract to google_maps_flutter) +// ───────────────────────────────────────────────────────────── - MarkerOptions({ - required this.position, - this.iconImage, - this.iconSize, - this.iconRotate, - this.textField, - this.textOffset, - }); - - maplibre.SymbolOptions toSymbolOptions() { - return maplibre.SymbolOptions( - geometry: position, - iconImage: iconImage, - iconSize: iconSize, - iconRotate: iconRotate, - textField: textField, - textOffset: textOffset != null ? Offset(0, textOffset!) : null, - ); - } +class MarkerId { + const MarkerId(this.value); + 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)'; } -class PolylineOptions { - final List coordinates; - final String? color; - final double? width; - final double? opacity; +class PolylineId { + const PolylineId(this.value); + 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)'; +} - PolylineOptions({ - required this.coordinates, - this.color, - this.width, - this.opacity, +class CircleId { + const CircleId(this.value); + 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)'; +} + +class PolygonId { + const PolygonId(this.value); + 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)'; +} + +// ───────────────────────────────────────────────────────────── +// Marker (mirrors google_maps_flutter Marker) +// ───────────────────────────────────────────────────────────── + +class Marker { + const Marker({ + required this.markerId, + required this.position, + this.alpha = 1.0, + this.anchor = const Offset(0.5, 1.0), + this.draggable = false, + this.flat = false, + this.icon = InlqBitmap.defaultMarker, + this.infoWindow = InfoWindow.noText, + this.rotation = 0.0, + this.visible = true, + this.zIndex = 0.0, + this.onTap, + this.onDragStart, + this.onDrag, + this.onDragEnd, }); - maplibre.LineOptions toLineOptions() { - return maplibre.LineOptions( - geometry: coordinates, - lineColor: color ?? IntaleqColors.primaryBlue, - lineWidth: width ?? 5.0, - lineOpacity: opacity ?? 1.0, + final MarkerId markerId; + final mgl.LatLng position; + + /// Opacity of the marker icon, from 0.0 (transparent) to 1.0 (opaque). + final double alpha; + + /// Icon anchor point as a fraction of icon size. (0.5, 1.0) = bottom-center. + final Offset anchor; + + /// Whether the marker can be dragged by the user. + final bool draggable; + + /// When true the marker is drawn flat against the map surface. + final bool flat; + + /// The icon image for the marker. + final InlqBitmap icon; + + /// Info window shown when the marker is tapped. + final InfoWindow infoWindow; + + /// Rotation of the marker in degrees clockwise from north. + final double rotation; + + /// Whether the marker is visible on the map. + final bool visible; + + /// Z-index for draw ordering among markers. + final double zIndex; + + /// Called when the marker is tapped. + final VoidCallback? onTap; + + /// Called when a drag starts. + final ValueChanged? onDragStart; + + /// Called repeatedly during drag. + final ValueChanged? onDrag; + + /// Called when drag ends. + final ValueChanged? onDragEnd; + + Marker copyWith({ + mgl.LatLng? position, + double? alpha, + Offset? anchor, + bool? draggable, + bool? flat, + InlqBitmap? icon, + InfoWindow? infoWindow, + double? rotation, + bool? visible, + double? zIndex, + VoidCallback? onTap, + ValueChanged? onDragStart, + ValueChanged? onDrag, + ValueChanged? onDragEnd, + }) { + return Marker( + markerId: markerId, + position: position ?? this.position, + alpha: alpha ?? this.alpha, + anchor: anchor ?? this.anchor, + draggable: draggable ?? this.draggable, + flat: flat ?? this.flat, + icon: icon ?? this.icon, + infoWindow: infoWindow ?? this.infoWindow, + rotation: rotation ?? this.rotation, + visible: visible ?? this.visible, + zIndex: zIndex ?? this.zIndex, + onTap: onTap ?? this.onTap, + onDragStart: onDragStart ?? this.onDragStart, + onDrag: onDrag ?? this.onDrag, + onDragEnd: onDragEnd ?? this.onDragEnd, + ); + } + + /// Internal: converts to MapLibre SymbolOptions. + mgl.SymbolOptions toSymbolOptions() { + return mgl.SymbolOptions( + geometry: position, + iconImage: icon.mapLibreImageId, + iconSize: icon.size, + iconRotate: rotation, + iconOpacity: alpha, + iconAnchor: _anchorToString(anchor), + draggable: draggable, + zIndex: zIndex.toInt(), + textField: infoWindow.title, + iconOffset: icon.offset, + ); + } + + static String _anchorToString(Offset anchor) { + if (anchor.dx == 0.5 && anchor.dy == 1.0) return 'bottom'; + if (anchor.dx == 0.5 && anchor.dy == 0.0) return 'top'; + if (anchor.dx == 0.0 && anchor.dy == 0.5) return 'left'; + if (anchor.dx == 1.0 && anchor.dy == 0.5) return 'right'; + if (anchor.dx == 0.5 && anchor.dy == 0.5) return 'center'; + return 'bottom'; + } + + @override + bool operator ==(Object o) => o is Marker && o.markerId == markerId; + @override + int get hashCode => markerId.hashCode; +} + +// ───────────────────────────────────────────────────────────── +// Polyline (mirrors google_maps_flutter Polyline) +// ───────────────────────────────────────────────────────────── + +class Polyline { + const Polyline({ + required this.polylineId, + required this.points, + this.color = const Color(0xFF0D47A1), + this.width = 5, + this.visible = true, + this.zIndex = 0, + this.geodesic = false, + this.onTap, + }); + + final PolylineId polylineId; + + /// The ordered list of points that make up the polyline. + final List points; + + /// Line stroke color. + final Color color; + + /// Line stroke width in screen pixels. + final int width; + + /// Whether the polyline is visible on the map. + final bool visible; + + final int zIndex; + + /// When true, the polyline follows the curvature of the Earth. + final bool geodesic; + + /// Called when the polyline is tapped. + final VoidCallback? onTap; + + Polyline copyWith({ + List? points, + Color? color, + int? width, + bool? visible, + int? zIndex, + bool? geodesic, + VoidCallback? onTap, + }) { + return Polyline( + polylineId: polylineId, + points: points ?? this.points, + color: color ?? this.color, + width: width ?? this.width, + visible: visible ?? this.visible, + zIndex: zIndex ?? this.zIndex, + geodesic: geodesic ?? this.geodesic, + onTap: onTap ?? this.onTap, + ); + } + + /// 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, lineJoin: 'round', ); } + + @override + bool operator ==(Object o) => o is Polyline && o.polylineId == polylineId; + @override + int get hashCode => polylineId.hashCode; } -class CircleOptions { - final maplibre.LatLng position; - final double radius; - final String? color; - final double? opacity; - final String? strokeColor; - final double? strokeWidth; +// ───────────────────────────────────────────────────────────── +// Circle (mirrors google_maps_flutter Circle) +// ───────────────────────────────────────────────────────────── - CircleOptions({ - required this.position, +class Circle { + const Circle({ + required this.circleId, + required this.center, required this.radius, - this.color, - this.opacity, - this.strokeColor, - this.strokeWidth, + this.fillColor = const Color(0x1A0D47A1), + this.strokeColor = const Color(0xFF0D47A1), + this.strokeWidth = 2, + this.visible = true, + this.zIndex = 0, + this.onTap, }); - maplibre.CircleOptions toMapLibreOptions() { - return maplibre.CircleOptions( - geometry: position, - circleRadius: radius, - circleColor: color ?? IntaleqColors.primaryBlue, - circleOpacity: opacity ?? 0.6, - circleStrokeColor: strokeColor, - circleStrokeWidth: strokeWidth, + final CircleId circleId; + + /// Center of the circle. + final mgl.LatLng center; + + /// Radius in meters. + final double radius; + + /// Fill color (with alpha for opacity). + final Color fillColor; + + /// Stroke color. + final Color strokeColor; + + /// Stroke width in pixels. + final int strokeWidth; + + final bool visible; + final int zIndex; + + /// Called when the circle is tapped. + final VoidCallback? onTap; + + Circle copyWith({ + mgl.LatLng? center, + double? radius, + Color? fillColor, + Color? strokeColor, + int? strokeWidth, + bool? visible, + int? zIndex, + VoidCallback? onTap, + }) { + return Circle( + circleId: circleId, + center: center ?? this.center, + radius: radius ?? this.radius, + fillColor: fillColor ?? this.fillColor, + strokeColor: strokeColor ?? this.strokeColor, + strokeWidth: strokeWidth ?? this.strokeWidth, + visible: visible ?? this.visible, + zIndex: zIndex ?? this.zIndex, + onTap: onTap ?? this.onTap, ); } + + /// Internal: converts to MapLibre CircleOptions. + /// Note: MapLibre circle radius is in pixels, not meters. For a true + /// meter-accurate circle, use [IntaleqMapController.addCircleAccurate]. + mgl.CircleOptions toCircleOptions() { + return mgl.CircleOptions( + geometry: center, + circleRadius: + radius / 10, // approximate; use addCircleAccurate for precision + circleColor: _colorToHex(fillColor), + circleOpacity: visible ? fillColor.opacity : 0.0, + circleStrokeColor: _colorToHex(strokeColor), + circleStrokeWidth: strokeWidth.toDouble(), + circleStrokeOpacity: visible ? strokeColor.opacity : 0.0, + ); + } + + @override + bool operator ==(Object o) => o is Circle && o.circleId == circleId; + @override + int get hashCode => circleId.hashCode; } -class PolygonOptions { - /// The list of rings. The first ring is the outer boundary, - /// and subsequent rings are holes. - final List> rings; - final String? fillColor; - final double? fillOpacity; - final String? outlineColor; +// ───────────────────────────────────────────────────────────── +// Polygon (mirrors google_maps_flutter Polygon) +// ───────────────────────────────────────────────────────────── - PolygonOptions({ - required this.rings, - this.fillColor, - this.fillOpacity, - this.outlineColor, +class 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.visible = true, + this.zIndex = 0, + this.geodesic = false, + this.onTap, }); - maplibre.FillOptions toFillOptions() { - return maplibre.FillOptions( + final PolygonId polygonId; + + /// Outer boundary of the polygon. + final List points; + + /// Holes inside the polygon (each is a list of LatLng). + final List> holes; + + /// Fill color. + final Color fillColor; + + /// Outline stroke color. + final Color strokeColor; + + /// Outline stroke width in pixels. + final int strokeWidth; + + final bool visible; + final int zIndex; + final bool geodesic; + + /// Called when the polygon is tapped. + final VoidCallback? onTap; + + Polygon copyWith({ + List? points, + List>? holes, + Color? fillColor, + Color? strokeColor, + int? strokeWidth, + bool? visible, + int? zIndex, + bool? geodesic, + VoidCallback? onTap, + }) { + return Polygon( + polygonId: polygonId, + points: points ?? this.points, + holes: holes ?? this.holes, + fillColor: fillColor ?? this.fillColor, + strokeColor: strokeColor ?? this.strokeColor, + strokeWidth: strokeWidth ?? this.strokeWidth, + visible: visible ?? this.visible, + zIndex: zIndex ?? this.zIndex, + geodesic: geodesic ?? this.geodesic, + onTap: onTap ?? this.onTap, + ); + } + + /// Internal: converts to MapLibre FillOptions. + /// The first ring is the outer boundary; subsequent rings are holes. + mgl.FillOptions toFillOptions() { + final rings = [points, ...holes]; + return mgl.FillOptions( geometry: rings, - fillColor: fillColor ?? IntaleqColors.fillGray, - fillOpacity: fillOpacity ?? 0.4, - fillOutlineColor: outlineColor ?? IntaleqColors.primaryBlue, + fillColor: _colorToHex(fillColor), + fillOpacity: visible ? fillColor.opacity : 0.0, + fillOutlineColor: _colorToHex(strokeColor), ); } + + @override + bool operator ==(Object o) => o is Polygon && o.polygonId == polygonId; + @override + int get hashCode => polygonId.hashCode; +} + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/// Converts a Flutter [Color] to a CSS hex string (e.g. '#0D47A1'). +String _colorToHex(Color color) { + return '#${color.red.toRadixString(16).padLeft(2, '0')}' + '${color.green.toRadixString(16).padLeft(2, '0')}' + '${color.blue.toRadixString(16).padLeft(2, '0')}'; } diff --git a/packages/flutter-sdk/lib/src/models/info_window.dart b/packages/flutter-sdk/lib/src/models/info_window.dart new file mode 100644 index 0000000..b43f106 --- /dev/null +++ b/packages/flutter-sdk/lib/src/models/info_window.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart' show VoidCallback; + +/// Text labels and a tap callback shown when a [Marker] is tapped. +/// +/// Mirrors `InfoWindow` from `google_maps_flutter`. +class InfoWindow { + const InfoWindow({ + this.title, + this.snippet, + this.onTap, + }); + + /// Displayed as the window heading. + final String? title; + + /// Displayed below the title as secondary text. + final String? snippet; + + /// Called when the info window itself is tapped. + final VoidCallback? onTap; + + /// An [InfoWindow] with no text (the default). + static const InfoWindow noText = InfoWindow(); + + InfoWindow copyWith({ + String? title, + String? snippet, + VoidCallback? onTap, + }) { + return InfoWindow( + title: title ?? this.title, + snippet: snippet ?? this.snippet, + onTap: onTap ?? this.onTap, + ); + } + + @override + String toString() => 'InfoWindow(title: $title, snippet: $snippet)'; +} diff --git a/packages/flutter-sdk/lib/src/models/types.dart b/packages/flutter-sdk/lib/src/models/types.dart new file mode 100644 index 0000000..5e23be3 --- /dev/null +++ b/packages/flutter-sdk/lib/src/models/types.dart @@ -0,0 +1,66 @@ +import 'package:maplibre_gl/maplibre_gl.dart' + show LatLngBounds, LatLng, CameraPosition; +import '../intaleq_map_controller.dart'; + +// ───────────────────────────────────────────────────────────── +// IntaleqMapType (mirrors MapType in google_maps_flutter) +// ───────────────────────────────────────────────────────────── + +/// The visual style of the base map tiles. +enum IntaleqMapType { + /// No base map tiles. + none, + + /// Dark premium Obsidian style — the Intaleq default. + normal, + + /// High-contrast light style. + light, + + /// Satellite imagery with road labels overlaid. + satellite, +} + +// ───────────────────────────────────────────────────────────── +// MinMaxZoomPreference (identical to google_maps_flutter) +// ───────────────────────────────────────────────────────────── + +/// Preferred zoom level constraints for the camera. +class MinMaxZoomPreference { + const MinMaxZoomPreference(this.minZoom, this.maxZoom); + + final double? minZoom; + final double? maxZoom; + + /// Unbounded zoom (default). + static const MinMaxZoomPreference unbounded = + MinMaxZoomPreference(null, null); + + @override + String toString() => 'MinMaxZoomPreference($minZoom, $maxZoom)'; +} + +// ───────────────────────────────────────────────────────────── +// CameraTargetBounds (identical to google_maps_flutter) +// ───────────────────────────────────────────────────────────── + +/// Constrains the camera target to a [LatLngBounds] region. +class CameraTargetBounds { + const CameraTargetBounds(this.bounds); + + final LatLngBounds? bounds; + + /// Unbounded (default). + static const CameraTargetBounds unbounded = CameraTargetBounds(null); + + @override + String toString() => 'CameraTargetBounds($bounds)'; +} + +// ───────────────────────────────────────────────────────────── +// Typedefs (same names as google_maps_flutter) +// ───────────────────────────────────────────────────────────── + +typedef MapCreatedCallback = void Function(IntaleqMapController controller); +typedef ArgumentCallback = void Function(T argument); +typedef CameraPositionCallback = void Function(CameraPosition position); diff --git a/packages/flutter-sdk/lib/src/styles.dart b/packages/flutter-sdk/lib/src/styles.dart index 8d297a8..0c3458f 100644 --- a/packages/flutter-sdk/lib/src/styles.dart +++ b/packages/flutter-sdk/lib/src/styles.dart @@ -1,13 +1,16 @@ +/// Predefined Intaleq Map tile style URLs. class IntaleqStyles { - /// Dark premium map style (The default for Intaleq) + IntaleqStyles._(); + + /// Dark premium Obsidian style — the Intaleq default. static String obsidian(String apiKey) => 'https://maps.intaleq.com/styles/obsidian/style.json?key=$apiKey'; - /// Light premium map style + /// High-contrast light style. static String light(String apiKey) => 'https://maps.intaleq.com/styles/light/style.json?key=$apiKey'; - /// Satellite hybrid style + /// Satellite imagery with road labels. static String satellite(String apiKey) => 'https://maps.intaleq.com/styles/satellite/style.json?key=$apiKey'; } diff --git a/packages/flutter-sdk/lib/src/utils/polyline_utils.dart b/packages/flutter-sdk/lib/src/utils/polyline_utils.dart index 4349659..b934fe4 100644 --- a/packages/flutter-sdk/lib/src/utils/polyline_utils.dart +++ b/packages/flutter-sdk/lib/src/utils/polyline_utils.dart @@ -1,10 +1,18 @@ -import 'package:maplibre_gl/maplibre_gl.dart'; +import 'dart:math' show sin, cos, sqrt, atan2, pi; +import 'package:maplibre_gl/maplibre_gl.dart' show LatLng; +/// Utility functions for working with encoded polylines and coordinates. class PolylineUtils { - /// Decode a Google-encoded polyline string into a list of LatLng coordinates. + PolylineUtils._(); + + // ── Encoding / Decoding ──────────────────────────────────── + + /// Decodes a [Google-encoded polyline](https://developers.google.com/maps/documentation/utilities/polylinealgorithm) + /// string into a list of [LatLng] coordinates. static List decode(String encoded) { - List points = []; - int index = 0, len = encoded.length; + final points = []; + int index = 0; + final len = encoded.length; int lat = 0, lng = 0; while (index < len) { @@ -14,7 +22,7 @@ class PolylineUtils { result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); - int dlat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1)); + final dlat = (result & 1) != 0 ? ~(result >> 1) : (result >> 1); lat += dlat; shift = 0; @@ -24,11 +32,61 @@ class PolylineUtils { result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); - int dlng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1)); + final dlng = (result & 1) != 0 ? ~(result >> 1) : (result >> 1); lng += dlng; points.add(LatLng(lat / 1E5, lng / 1E5)); } return points; } + + /// Encodes a list of [LatLng] points to a Google-encoded polyline string. + static String encode(List points) { + final result = StringBuffer(); + int prevLat = 0, prevLng = 0; + + for (final point in points) { + final lat = (point.latitude * 1E5).round(); + final lng = (point.longitude * 1E5).round(); + _encodeValue(result, lat - prevLat); + _encodeValue(result, lng - prevLng); + prevLat = lat; + prevLng = lng; + } + return result.toString(); + } + + static void _encodeValue(StringBuffer buf, int value) { + var v = value < 0 ? ~(value << 1) : (value << 1); + while (v >= 0x20) { + buf.writeCharCode(((0x20 | (v & 0x1f)) + 63)); + v >>= 5; + } + buf.writeCharCode((v + 63)); + } + + // ── Distance ─────────────────────────────────────────────── + + /// Returns the Haversine distance in **metres** between two [LatLng] points. + static double distanceBetween(LatLng a, LatLng b) { + const earthRadius = 6371000.0; // metres + final dLat = _toRad(b.latitude - a.latitude); + final dLng = _toRad(b.longitude - a.longitude); + final sinDLat = sin(dLat / 2); + final sinDLng = sin(dLng / 2); + final h = sinDLat * sinDLat + + cos(_toRad(a.latitude)) * cos(_toRad(b.latitude)) * sinDLng * sinDLng; + return 2 * earthRadius * atan2(sqrt(h), sqrt(1 - h)); + } + + /// Returns the total length of a polyline in metres. + static double totalLength(List points) { + double total = 0; + for (int i = 0; i < points.length - 1; i++) { + total += distanceBetween(points[i], points[i + 1]); + } + return total; + } + + static double _toRad(double deg) => deg * pi / 180.0; } diff --git a/packages/flutter-sdk/pubspec.lock b/packages/flutter-sdk/pubspec.lock index 5fe4bf4..6b34157 100644 --- a/packages/flutter-sdk/pubspec.lock +++ b/packages/flutter-sdk/pubspec.lock @@ -49,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" fake_async: dependency: transitive description: @@ -73,14 +65,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -96,62 +80,6 @@ packages: description: flutter source: sdk version: "0.0.0" - geolocator: - dependency: "direct main" - description: - name: geolocator - sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5 - url: "https://pub.dev" - source: hosted - version: "10.1.1" - geolocator_android: - dependency: transitive - description: - name: geolocator_android - sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d - url: "https://pub.dev" - source: hosted - version: "4.6.2" - geolocator_apple: - dependency: transitive - description: - name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 - url: "https://pub.dev" - source: hosted - version: "2.3.13" - geolocator_platform_interface: - dependency: transitive - description: - name: geolocator_platform_interface - sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" - url: "https://pub.dev" - source: hosted - version: "4.2.6" - geolocator_web: - dependency: transitive - description: - name: geolocator_web - sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - geolocator_windows: - dependency: transitive - description: - name: geolocator_windows - sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" - url: "https://pub.dev" - source: hosted - version: "0.2.5" - get: - dependency: "direct main" - description: - name: get - sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" - url: "https://pub.dev" - source: hosted - version: "4.7.3" http: dependency: "direct main" description: @@ -176,14 +104,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" - intl: - dependency: transitive - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" js: dependency: transitive description: @@ -192,14 +112,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" leak_tracker: dependency: transitive description: @@ -296,14 +208,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" posix: dependency: transitive description: @@ -373,14 +277,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" vector_math: dependency: transitive description: diff --git a/packages/flutter-sdk/pubspec.yaml b/packages/flutter-sdk/pubspec.yaml index 2c32af6..15e8425 100644 --- a/packages/flutter-sdk/pubspec.yaml +++ b/packages/flutter-sdk/pubspec.yaml @@ -1,7 +1,11 @@ name: intaleq_maps -description: Premium Flutter SDK for Intaleq Map Platform (Jordan & Syria). -version: 1.0.0 +description: > + Premium Flutter SDK for the Intaleq Map Platform (Jordan & Syria). + A drop-in Google Maps Flutter replacement backed by MapLibre GL. +version: 2.0.0 homepage: https://intaleqapp.com +repository: https://github.com/Hamza-Ayed/intaleq_maps +issue_tracker: https://github.com/Hamza-Ayed/intaleq_maps/issues environment: sdk: ">=3.0.0 <4.0.0" @@ -12,12 +16,6 @@ dependencies: sdk: flutter maplibre_gl: ^0.19.0 http: ^1.1.0 - geolocator: ^10.1.0 - get: ^4.6.6 - latlong2: ^0.9.0 - -repository: https://github.com/Hamza-Ayed/intaleq_maps -issue_tracker: https://github.com/Hamza-Ayed/intaleq_maps/issues dev_dependencies: flutter_test: @@ -25,4 +23,4 @@ dev_dependencies: lints: ^2.1.0 flutter: - uses-material-design: true + uses-material-design: true \ No newline at end of file